From 1a53681de160ad5eac47574f4c8d5da1728e8060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 27 Sep 2026 09:05:54 +0000 Subject: [PATCH] fix(studio): an automation lane edit is refused on a locked clip and reports how it saved (#4533) * fix(studio): an automation lane edit is refused on a locked clip and reports how it saved Lane drags, Delete and paste save through the timeline's gated clip or group save instead of the property panel's commit, so the host's canEdit applies and the commit resolves saved, refused or failed. A landed save refreshes the lane store and the panel's selection, as the panel's commit did. * fix(studio): a refused automation save puts the preview and the lane back A save that does not land (recording, no id, a failed write) reverts the live preview it was given, the lane drops a draft whose save came back refused or failed, the no-id and no-project failures toast like the others, and the panel refresh reads the current selection. The panel commit's unused coalesce option goes, and the lane value reuses automationAttrValue. * fix(studio): a failed save restores the preview it was given, not an older one Each timeline save claims the preview's before-value when it starts, so a gesture begun while it is in flight records its own and a later failed save cannot put back a value from before an earlier save landed. * chore(repo): allow the automation gesture key files this branch removes * fix(studio): a refused automation save leaves the lane store and paste mark as saved Reverting a live value re-syncs the lane store from the restored preview, so an overlapping save that landed first cannot leave the store holding a later value that failed. A paste whose save is refused or fails gives back the selection and paste chain it replaced. * fix(studio): a failed save settles on what the file holds and a refused paste marks nothing A save that does not land settles the preview and store on the file's value (the claimed before-value only when the file cannot be read), and leaves a gesture begun meanwhile alone apart from correcting its before-value, so two overlapping failed saves cannot leave either on a value that never landed. A paste selects and marks its span only once it lands and only over the selection it was pasted at. * fix(studio): a failed save reads the file only after writes queued to it land The settle read goes through the same per-path queue as the writes, so a save that fails while another is queued settles the preview, the store and any newer gesture's before-value on what that queued save wrote. * fix(studio): an older failed save never settles over a newer edit on the same lane Live-preview bookkeeping moves to createLiveLanes, one owner for both timeline saves: each lane key carries a generation that every live write and save start advances, and a save settles the preview and store (on its landed value, or on the file after a failure) only while it is still the newest. A paste marks its span only if it is still the latest paste. * test(studio): a newer live drag keeps its preview when an older save recovers * fix(studio): every finished save records what the file holds, even under a newer edit A save's settle now always writes the file's value (landed, or read back after a failure) into the lane store and a live gesture's before-value, and moves the preview only while nothing newer has touched the lane. The clip store is set from that value rather than copied from the preview. A paste marks its span only while its clip is still the selected one. * fix(studio): an older automation save never overrides a newer gesture on its lane A failed save's recovery read could settle after a newer save started, and the save's own optimistic preview patch and failure unwind ran over a newer drag. Every preview write a save makes now goes through the lane's ownership check, only the newest save records its value, and a refused drag hands the lane back to the newest pending save. * fix(studio): timeline attribute saves land in the order they start An attribute save read the file before joining its write queue, so an older save with a slow read could land after a newer one, overwrite it on disk and leave the lane showing the newer value. The save now joins the queue first and reads, patches and writes inside it. A save moves the preview only while it is the newest save and no drag is live, which replaces the per-lane generation. * refactor(studio): name the in-queue history write for the queue it needs * test(studio): pin save start order and a newer release's preview mid-flight * fix(studio): a drag keeps its points when an older save lands under it An older save that landed mid-drag wrote the lane store, the lane dropped its draft, and the release committed the older points. The lane now keeps its draft while a gesture is live. A save that cannot read the file back settles on the last value read or saved on its lane, never on an unsaved one, and an older save that lands records its value there even when a newer save is pending. * fix(studio): a lane's first failed save still puts its drag back With the claimed-value fallback gone, a first save on a lane that failed and could not read the file back settled on nothing, leaving the drag playing. A save begun with no other save pending now records the value under its drag as verified, since that is what the file last settled on. * fix(studio): only a before-value read with no save pending counts as saved The value under a drag was trusted when its save started with nothing pending, but it was read when the drag began, possibly over a paste that later failed. A lane now marks a before-value clean only when no save was pending as the drag began. A group lane's preview carries its store mirror, as its live writes do. * fix(studio): a lane's saved value never crosses into another project The lane bookkeeping outlives a project switch and its keys had no project, so a value saved in one project could settle into another project's clip with the same key when a save there failed and could not read the file back. Lane keys now carry the open project, and a save moves the preview or store only while the project it was made in is still open. * fix(studio): a failed save reads its file back from its own project A save that failed after a project switch read its recovery from the newly opened project and recorded that project's value as its own. Each save now captures its project when it starts and reads back from it. * fix(studio): a failed save never brings back a value an undo removed A lane kept its last verified value after its saves settled, so after an undo a save that failed and could not read the file back settled on the removed value. The value now lives only while a save on the lane is in flight, and each save records what its own in-queue file read found. The group preview's store mirror is no longer needed and is gone. * test(studio): pass the file-read callback in the direct attribute-save tests * fix(studio): a drag whose before-value a save settled counts as clean When a settle rewrites a live drag's before-value to a verified value, that value is known saved, so the drag's own save can fall back on it even when its read and read-back both fail. Adds a test for a failed save reading back from its own project while another save is in flight across a switch. * fix(studio): a save started in one composition never lands in another of the same project Live lanes were scoped to the project only, so a save held across a switch to another composition of that project settled its value into the new composition's preview (clip and group lanes) and store (group lane). Lanes are now scoped to the project and the composition open when the save starts. createLiveLanes moves to its own module for the 600-line cap. * fix(studio): an undo during or after a lane gesture stays undone when the next save fails A live gesture records the lane's value before it starts, and a later save that cannot read the file settles on that value. An undo mid-drag, or after a gesture that ended without saving, left that value stale, so the save brought back what the undo removed. Every undo and redo now re-reads each open lane's value from the files it restored. * fix(studio): undoing group automation clears it from the members' lanes at once The group-info cache only noticed a change when its MutationObserver callback ran, so the soft restore's store sync, which reads it synchronously, mirrored the pre-undo group automation onto the members. A read now counts mutations the observer has not delivered yet. * fix(studio): an undo reaches every lane and pending save it rewrote, in any composition The undo re-read only covered lanes in the open composition and live gestures. History is project-wide, so undoing from another composition left the rewritten composition's lanes on the pre-undo value, and a save released while the undo was in flight kept that value as its fallback. The re-read now covers every lane of the project on a restored path, including saves still pending, and an undone file it cannot read makes the value untrusted instead of dropped. A missing element reads as unreadable, the same as a save's read-back. * fix(studio): a cancelled fade, stretch or preset audition leaves no stale lane value behind A cancel put the gesture's start back through a live write, which left the lane's before-value in place until the next save trusted it, even after the property panel, the source editor or an agent had changed the file. The fade cancel, the edge-stretch cancel and leaving a preset audition now end through the lanes' revert, so a before-value exists only during a live gesture. * test(studio): pin that a group write is read back fresh in the same task * style(studio): drop two comments the names already carry * fix(studio): a failed group save reads its own file back; fade cancels work without revert - The group save's failure read-back used the file of whatever composition was loaded when the write failed. It now reads the file captured when the save started, as the clip save does. - A fade cancel writes its start back live before it reverts, like the other cancel sites, so a host without the optional revert callback still puts the fade back. - The edge-stretch cancel comment says what the code now does. - The save test harness passes the revert callbacks, as the app does; without them the cancelled-gesture tests exercised a host that lacks them. * refactor(studio): trim three long comments on live automation saves --- packages/studio/src/App.tsx | 5 +- .../editor/TimelineFxPopover.test.tsx | 2 +- .../components/editor/TimelineFxPopover.tsx | 2 +- .../src/components/editor/useFxAudition.ts | 6 +- .../nle/useTimelineEditCallbacks.ts | 4 + .../studio/src/contexts/DomEditContext.tsx | 4 + .../src/contexts/TimelineEditContext.tsx | 2 + packages/studio/src/hooks/liveLanes.ts | 147 ++ .../src/hooks/timelineAudioGroupVolume.ts | 128 +- .../src/hooks/timelineEditPermission.ts | 10 +- .../src/hooks/timelineEditingHelpers.test.ts | 2 + .../src/hooks/timelineEditingHelpers.ts | 107 +- .../src/hooks/timelineElementFxAttribute.ts | 118 +- packages/studio/src/hooks/useAppHotkeys.ts | 2 +- .../useAutomationSelectionKeyboard.test.tsx | 146 +- .../hooks/useAutomationSelectionKeyboard.ts | 46 +- .../src/hooks/useDomEditAttributeCommits.ts | 20 +- .../studio/src/hooks/useDomEditSession.ts | 1 + .../studio/src/hooks/useEditHistoryActions.ts | 7 +- .../studio/src/hooks/useTimelineEditing.ts | 32 +- .../TimelineAutomationLane.test.tsx | 38 + .../components/TimelineAutomationLane.tsx | 31 +- .../TimelineAutomationLaneSlot.test.tsx | 2 - .../components/TimelineClipFades.test.tsx | 27 +- .../player/components/TimelineClipFades.tsx | 7 +- .../player/components/TimelineFxButton.tsx | 2 +- .../player/components/TimelineGroupHeader.tsx | 2 +- .../components/TimelineGroupRow.test.tsx | 1 - .../player/components/TimelineGroupRow.tsx | 28 +- .../player/components/TimelineTrackHeader.tsx | 27 +- .../components/automationGestureKeys.test.ts | 49 - .../components/automationGestureKeys.ts | 49 - .../components/groupAutomationElement.ts | 10 +- .../player/components/timelineCallbacks.ts | 2 + .../components/useAutomationEdgeStretch.ts | 8 +- .../components/useAutomationLaneGestures.ts | 2 +- .../useAutomationLanes.save.test.tsx | 1406 +++++++++++++++++ .../player/components/useAutomationLanes.ts | 132 +- .../src/player/lib/automationStoreSync.ts | 33 +- .../studio/src/player/lib/timelineDOM.test.ts | 10 +- .../src/player/lib/timelineGroupInfo.ts | 46 +- .../studio/src/utils/studioFileHistory.ts | 68 +- scripts/check-no-main-deletions.mjs | 8 + 43 files changed, 2283 insertions(+), 496 deletions(-) create mode 100644 packages/studio/src/hooks/liveLanes.ts delete mode 100644 packages/studio/src/player/components/automationGestureKeys.test.ts delete mode 100644 packages/studio/src/player/components/automationGestureKeys.ts create mode 100644 packages/studio/src/player/components/useAutomationLanes.save.test.tsx diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 76e36c5ecd..eb7c2b15a6 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -261,7 +261,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St handleDuplicate, onResetKeyframes: () => resetKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), - onAfterUndoRedo: () => invalidateGsapCacheRef.current(), + onAfterUndoRedo: (restore) => { + invalidateGsapCacheRef.current(); + timelineEditing.restoreLiveLanes(restore); + }, onGroupSelection: () => domEditSessionRef.current.handleGroupSelection(), onUngroupSelection: () => domEditSessionRef.current.handleUngroupSelection(), activeCompPath, diff --git a/packages/studio/src/components/editor/TimelineFxPopover.test.tsx b/packages/studio/src/components/editor/TimelineFxPopover.test.tsx index 2a3249b432..2da6645273 100644 --- a/packages/studio/src/components/editor/TimelineFxPopover.test.tsx +++ b/packages/studio/src/components/editor/TimelineFxPopover.test.tsx @@ -87,7 +87,7 @@ describe("TimelineFxPopover", () => { act(() => { button?.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); }); - expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN); + expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN, true); }); it("Escape closes without letting the keystroke propagate past the popover", () => { diff --git a/packages/studio/src/components/editor/TimelineFxPopover.tsx b/packages/studio/src/components/editor/TimelineFxPopover.tsx index 3f655c020c..7f305b18d9 100644 --- a/packages/studio/src/components/editor/TimelineFxPopover.tsx +++ b/packages/studio/src/components/editor/TimelineFxPopover.tsx @@ -75,7 +75,7 @@ export interface TimelineFxPopoverProps { * selected clip's attribute — the caller resolves which). */ onChainChange: (next: HfAudioFxChain) => void; /** Preview a hypothetical chain on the running graph without persisting. */ - onChainPreview?: (next: HfAudioFxChain) => void; + onChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void; onAuditionTransport?: (on: boolean) => void; /** Select the target the way clicking it in the timeline does, and ensure * the property panel's Audio FX group is expanded. */ diff --git a/packages/studio/src/components/editor/useFxAudition.ts b/packages/studio/src/components/editor/useFxAudition.ts index 3c7d9e6b61..78b4a6dbf9 100644 --- a/packages/studio/src/components/editor/useFxAudition.ts +++ b/packages/studio/src/components/editor/useFxAudition.ts @@ -11,7 +11,7 @@ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx"; export function useFxAudition( chain: HfAudioFxChain, - onChainPreview: ((chain: HfAudioFxChain) => void) | undefined, + onChainPreview: ((chain: HfAudioFxChain, ended?: boolean) => void) | undefined, onAuditionTransport: ((on: boolean) => void) | undefined, ) { /** @@ -45,7 +45,7 @@ export function useFxAudition( // Stop before reverting, for the mirror of that reason — the last thing // heard should be the preset, not a frame of the chain coming back. onAuditionTransport?.(false); - onChainPreview(auditionBase.current); + onChainPreview(auditionBase.current, true); auditionBase.current = null; } }, @@ -96,7 +96,7 @@ export function useFxAudition( () => () => { if (auditionBase.current) { transportRef.current?.(false); - previewRef.current?.(auditionBase.current); + previewRef.current?.(auditionBase.current, true); } }, [], diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index 37e530b09d..b965082061 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -41,6 +41,7 @@ export interface TimelineEditCallbackDeps { handleToggleTrackHidden: (track: number, hidden: boolean) => Promise | void; setAudioGroupAttribute: { setLive: (groupId: string, attr: string, value: string | null) => void; + revertLive?: (groupId: string, attr: string) => void; setQuiet: ( groupId: string, attr: string, @@ -61,6 +62,7 @@ export interface TimelineEditCallbackDeps { /** C1's single-clip FX write, addressed by the clip itself. */ setElementFxAttribute?: { setLive: (element: TimelineElement, attr: string, value: string | null) => void; + revertLive?: (element: TimelineElement, attr: string) => void; setQuiet: ( element: TimelineElement, attr: string, @@ -216,9 +218,11 @@ export function useTimelineEditCallbacks({ onToggleTrackHidden: handleToggleTrackHidden, onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive, onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet, + onRevertAudioGroupAttributeLive: setAudioGroupAttribute.revertLive, onGroupClips: handleGroupClips, onSetElementAttributeLive: setElementFxAttribute?.setLive, onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet, + onRevertElementAttributeLive: setElementFxAttribute?.revertLive, onBlockedEditAttempt: handleBlockedTimelineEdit, onSplitElement: handleTimelineElementSplit, onRazorSplit: handleRazorSplit, diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 6c1eba1e17..c39400c0d3 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -13,6 +13,7 @@ export interface DomEditActionsValue extends Pick< | "handlePreviewCanvasPointerLeave" | "applyDomSelection" | "clearDomSelection" + | "refreshDomEditSelectionFromPreview" | "handleDomStyleCommit" | "handleDomStyleCommitForSelection" | "handleDomAttributeCommit" @@ -155,6 +156,7 @@ export function DomEditProvider({ handlePreviewCanvasPointerLeave, applyDomSelection, clearDomSelection, + refreshDomEditSelectionFromPreview, handleDomStyleCommit, handleDomStyleCommitForSelection, handleDomAttributeCommit, @@ -248,6 +250,7 @@ export function DomEditProvider({ handlePreviewCanvasPointerLeave, applyDomSelection, clearDomSelection, + refreshDomEditSelectionFromPreview, handleDomStyleCommit, handleDomStyleCommitForSelection, handleDomAttributeCommit, @@ -322,6 +325,7 @@ export function DomEditProvider({ handlePreviewCanvasPointerLeave, applyDomSelection, clearDomSelection, + refreshDomEditSelectionFromPreview, handleDomStyleCommit, handleDomStyleCommitForSelection, handleDomAttributeCommit, diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index bc117e8d0b..27b1f77d41 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -44,9 +44,11 @@ export function TimelineEditProvider({ value.onToggleTrackHidden, value.onSetAudioGroupAttributeLive, value.onSetAudioGroupAttributeQuiet, + value.onRevertAudioGroupAttributeLive, value.onGroupClips, value.onSetElementAttributeLive, value.onSetElementAttributeQuiet, + value.onRevertElementAttributeLive, value.onBlockedEditAttempt, value.onSplitElement, value.onRazorSplit, diff --git a/packages/studio/src/hooks/liveLanes.ts b/packages/studio/src/hooks/liveLanes.ts new file mode 100644 index 0000000000..91d1acad42 --- /dev/null +++ b/packages/studio/src/hooks/liveLanes.ts @@ -0,0 +1,147 @@ +import { useRef, useState } from "react"; +import { readTargetAttribute, type PatchTarget } from "./timelineEditingHelpers"; + +/** Where a lane's value lives in source, so an undo can re-read it. */ +export interface LiveLaneSource { + path: string; + target: PatchTarget | null; + attr: string; +} + +/** The files an undo or redo rewrote. */ +export interface LiveLaneRestore { + paths?: string[]; + files?: Record; +} + +/** Per-lane live-preview state: the value before a gesture, pending saves, the last verified value. */ +function createLiveLanes(project: () => string, composition: () => string) { + const before = new Map(); + const sources = new Map(); + const pending = new Map>(); + const verified = new Map(); + // Lanes whose before-value was read with no save pending, so it is what the file holds. + const clean = new Set(); + let saves = 0; + const take = (key: string): string | null | undefined => { + const claimed = before.has(key) ? (before.get(key) ?? null) : undefined; + before.delete(key); + clean.delete(key); + if (!pending.has(key)) sources.delete(key); + return claimed; + }; + const overtaken = (key: string, save: number): boolean => + [...(pending.get(key) ?? [])].some((newer) => newer > save); + const rebase = (key: string, value: string | null | undefined): void => { + if (value === undefined) { + clean.delete(key); + verified.delete(key); + return; + } + if (before.has(key)) before.set(key, value); + if (pending.has(key)) verified.set(key, value); + if (pending.has(key)) clean.delete(key); + else clean.add(key); + }; + const scoped = (laneKey: string): string => `${project()}\n${composition()}\n${laneKey}`; + return { + preview(laneKey: string, readCurrent: () => string | null, source: LiveLaneSource): void { + const key = scoped(laneKey); + if (before.has(key)) return; + before.set(key, readCurrent()); + sources.set(key, source); + if (!pending.has(key)) clean.add(key); + }, + // A save. Its settle records what the file holds: `saved`, else the last value verified on + // this lane (a landed save, a read-back, or a clean before-value), never an unsaved one. + // Store and preview follow only while no newer save is pending and its composition is open, + // and the preview only while no drag is live. + claim(laneKey: string, apply: LiveLaneApply, source: LiveLaneSource): LiveLaneSave { + const key = scoped(laneKey); + const open = () => scoped(laneKey) === key; + const trusted = clean.has(key); + const claimed = take(key); + const mine = ++saves; + sources.set(key, source); + if (claimed !== undefined && trusted) verified.set(key, claimed); + pending.set(key, (pending.get(key) ?? new Set()).add(mine)); + const preview = (value: string | null) => { + if (open() && !before.has(key) && !overtaken(key, mine)) apply.preview(value); + }; + return { + preview, + read(value) { + verified.set(key, value); + }, + settle(saved) { + const inFlight = pending.get(key); + inFlight?.delete(mine); + if (saved !== undefined) verified.set(key, saved); + const value = verified.get(key); + // With no save left in flight the file can change under it (undo, an outside edit). + if (!inFlight?.size) { + pending.delete(key); + verified.delete(key); + if (!before.has(key)) sources.delete(key); + } + if (value === undefined || overtaken(key, mine) || !open()) return; + if (before.has(key)) { + before.set(key, value); + clean.add(key); + } + apply.store(value); + preview(value); + }, + }; + }, + // A gesture refused before it saved: put its before-value back, which hands the lane + // back to any save still pending on it. + revert(laneKey: string, apply: LiveLaneApply): void { + const claimed = take(scoped(laneKey)); + if (claimed === undefined) return; + apply.preview(claimed); + apply.store(claimed); + }, + // An undo or redo rewrote files under the project's live gestures and pending saves, in any + // composition: each takes what the restored file holds, or stops trusting a value it cannot read. + restore({ paths = [], files }: LiveLaneRestore): void { + const inProject = `${project()}\n`; + for (const [key, source] of sources) { + if (key.startsWith(inProject) && paths.includes(source.path)) { + rebase(key, restoredValue(source, files)); + } + } + }, + }; +} + +function restoredValue({ path, target, attr }: LiveLaneSource, files: LiveLaneRestore["files"]) { + const html = files?.[path]?.restored; + return html === undefined || !target ? undefined : readTargetAttribute(html, target, attr); +} + +/** A hook's live lanes, scoped to the project and composition open when each save starts. */ +export function useLiveLanes( + projectIdRef: { readonly current: string | null }, + activeCompPath: string | null, +) { + const compositionRef = useRef(activeCompPath); + compositionRef.current = activeCompPath; + const [lanes] = useState(() => + createLiveLanes( + () => projectIdRef.current ?? "", + () => compositionRef.current ?? "", + ), + ); + return lanes; +} + +interface LiveLaneSave { + preview: (value: string | null) => void; + read: (value: string | null) => void; + settle: (saved?: string | null) => void; +} +interface LiveLaneApply { + preview: (value: string | null) => void; + store: (value: string | null) => void; +} diff --git a/packages/studio/src/hooks/timelineAudioGroupVolume.ts b/packages/studio/src/hooks/timelineAudioGroupVolume.ts index edac4de9fb..0aa120d4ff 100644 --- a/packages/studio/src/hooks/timelineAudioGroupVolume.ts +++ b/packages/studio/src/hooks/timelineAudioGroupVolume.ts @@ -1,5 +1,9 @@ -import { useCallback, useRef } from "react"; -import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission"; +import { useCallback } from "react"; +import { + failedTimelineSave, + projectForTimelineSave, + type TimelineEditOutcome, +} from "./timelineEditPermission"; import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx"; import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation"; import { usePlayerStore } from "../player"; @@ -8,8 +12,10 @@ import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo"; import { buildPatchTarget, persistElementAttribute, + readSavedAttribute, type RecordEditInput, } from "./timelineEditingHelpers"; +import { useLiveLanes, type LiveLaneRestore, type LiveLaneSource } from "./liveLanes"; import type { MutableRef, UseTimelineElementVisibilityEditingInput, @@ -146,6 +152,8 @@ interface SetAudioGroupAttributeInput { value: string | null; label: string; previewIframe: HTMLIFrameElement | null; + patchLive: (value: string | null) => void; + onFileRead: (value: string | null) => void; writeProjectFile: (path: string, content: string) => Promise; recordEdit: (input: RecordEditInput) => Promise; pendingTimelineEditPathRef: MutableRef>; @@ -157,6 +165,28 @@ interface SetAudioGroupAttributeInput { * `createAudioGroupAndAssignMembers`'s save shape but for a single element * and attribute rather than a member-assignment sweep. */ +function groupSaveTarget( + previewIframe: HTMLIFrameElement | null, + groupId: string, + activeCompPath: string | null, +) { + const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null; + return { + targetPath: resolveGroupSourceFile(groupEl) || activeCompPath || "index.html", + patchTarget: buildPatchTarget({ domId: groupId }), + }; +} + +function groupLiveSource( + previewIframe: HTMLIFrameElement | null, + groupId: string, + activeCompPath: string | null, + attr: string, +): LiveLaneSource { + const { targetPath, patchTarget } = groupSaveTarget(previewIframe, groupId, activeCompPath); + return { path: targetPath, target: patchTarget, attr }; +} + async function setAudioGroupAttribute({ projectId, activeCompPath, @@ -165,6 +195,8 @@ async function setAudioGroupAttribute({ value, label, previewIframe, + patchLive, + onFileRead, writeProjectFile, recordEdit, pendingTimelineEditPathRef, @@ -177,9 +209,7 @@ async function setAudioGroupAttribute({ // means `readTagSnippetByTarget` finds nothing and every mute, fader move and // FX preset throws "Unable to patch element in index.html". Every sibling // timeline writer already routes `element.sourceFile || activeCompPath`. - const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null; - const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html"; - const patchTarget = buildPatchTarget({ domId: groupId }); + const { targetPath, patchTarget } = groupSaveTarget(previewIframe, groupId, activeCompPath); if (!patchTarget) return null; return persistElementAttribute({ @@ -192,7 +222,8 @@ async function setAudioGroupAttribute({ writeProjectFile, recordEdit, pendingTimelineEditPathRef, - patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v), + patchLive, + onFileRead, }); } @@ -221,42 +252,43 @@ export function useSetAudioGroupAttribute({ label: string, ) => Promise; revertLive: (groupId: string, attr: string) => void; + restoreLive: (restore: LiveLaneRestore) => void; } { - const liveBeforeRef = useRef(new Map()); + const liveLanes = useLiveLanes(projectIdRef, activeCompPath); const setLive = useCallback( (groupId: string, attr: string, value: string | null) => { const key = audioGroupAttributeLiveKey(groupId, attr); const target = previewIframeRef.current?.contentDocument?.getElementById(groupId); - if (!liveBeforeRef.current.has(key)) { - liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null); - } + liveLanes.preview( + key, + () => target?.getAttribute(attr) ?? null, + groupLiveSource(previewIframeRef.current, groupId, activeCompPath, attr), + ); patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value); // Live too, not just on commit: a fader drag is `setLive` per frame and // `setQuiet` once on release, so without this the strip's own readout // fights the drag. syncStoredGroupAttribute(groupId, attr, value); }, + [liveLanes, previewIframeRef, activeCompPath], + ); + const laneApply = useCallback( + (groupId: string, attr: string) => ({ + preview: (value: string | null) => + patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value), + store: (value: string | null) => syncStoredGroupAttribute(groupId, attr, value), + }), [previewIframeRef], ); + const claimLive = useCallback( + (groupId: string, attr: string, source: LiveLaneSource) => + liveLanes.claim(audioGroupAttributeLiveKey(groupId, attr), laneApply(groupId, attr), source), + [liveLanes, laneApply], + ); const revertLive = useCallback( - (groupId: string, attr: string) => { - const key = audioGroupAttributeLiveKey(groupId, attr); - if (!liveBeforeRef.current.has(key)) return; - patchLiveGroupAttribute( - previewIframeRef.current, - groupId, - attr, - liveBeforeRef.current.get(key) ?? null, - ); - syncStoredGroupAttribute( - groupId, - attr, - previewIframeRef.current?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? - null, - ); - liveBeforeRef.current.delete(key); - }, - [previewIframeRef], + (groupId: string, attr: string) => + liveLanes.revert(audioGroupAttributeLiveKey(groupId, attr), laneApply(groupId, attr)), + [liveLanes, laneApply], ); const setQuiet = useCallback( async ( @@ -265,8 +297,19 @@ export function useSetAudioGroupAttribute({ value: string | null, label: string, ): Promise => { - const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast); - if (typeof pid !== "string") return pid; + const project = projectIdRef.current; + const pid = projectForTimelineSave(isRecordingRef?.current, project, showToast); + // Settles on what the file holds, so overlapping saves that fail cannot leave + // the preview or the store on a value that never landed. + const source = groupLiveSource(previewIframeRef.current, groupId, activeCompPath, attr); + const live = claimLive(groupId, attr, source); + const unsaved = async (outcome: TimelineEditOutcome): Promise => { + live.settle( + await readSavedAttribute(project, source.path, source.target, attr, writeProjectFile), + ); + return outcome; + }; + if (typeof pid !== "string") return unsaved(pid); try { const written = await setAudioGroupAttribute({ projectId: pid, @@ -276,33 +319,24 @@ export function useSetAudioGroupAttribute({ value, label, previewIframe: previewIframeRef.current, + patchLive: live.preview, + onFileRead: live.read, writeProjectFile, recordEdit, pendingTimelineEditPathRef, }); - liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr)); - if (!written) return { status: "failed", reason: "This group has no id to save it by" }; - syncStoredGroupAttribute(groupId, attr, value); + if (!written) + return unsaved(failedTimelineSave("This group has no id to save it by", showToast)); + live.settle(value); return { status: "saved" }; } catch (error) { - // `persistElementAttribute` leaves the live DOM at the previous value - // however it failed — it unwinds a failed save, and an unresolvable - // target now throws before patching at all. But `setLive` mirrored the - // in-progress value into the store on every drag frame — so without - // this the fader reads 0.4 while - // the preview and the file are both back at 1.0, and nothing re-parses - // to correct it (a live patch causing no parse is this mirror's whole - // premise). Re-mirror from the DOM, which is now authoritative again. - const live = previewIframeRef.current?.contentDocument?.getElementById(groupId); - syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null); console.error("[Timeline] Failed to set group attribute", error); const message = error instanceof Error ? error.message : "Failed to update group"; - showToast(message); - liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr)); - return { status: "failed", reason: message }; + return unsaved(failedTimelineSave(message, showToast)); } }, [ + claimLive, activeCompPath, previewIframeRef, writeProjectFile, @@ -313,5 +347,5 @@ export function useSetAudioGroupAttribute({ projectIdRef, ], ); - return { setLive, setQuiet, revertLive }; + return { setLive, setQuiet, revertLive, restoreLive: liveLanes.restore }; } diff --git a/packages/studio/src/hooks/timelineEditPermission.ts b/packages/studio/src/hooks/timelineEditPermission.ts index 7df1d7eff4..6ba0446415 100644 --- a/packages/studio/src/hooks/timelineEditPermission.ts +++ b/packages/studio/src/hooks/timelineEditPermission.ts @@ -21,7 +21,15 @@ export function projectForTimelineSave( showToast("Cannot edit timeline while recording", "error"); return { status: "refused", reason: "Cannot edit timeline while recording" }; } - return projectId ?? { status: "failed", reason: "No project is open" }; + return projectId ?? failedTimelineSave("No project is open", showToast); +} + +export function failedTimelineSave( + reason: string, + showToast: (message: string, tone?: "error" | "info") => void, +): TimelineEditOutcome { + showToast(reason); + return { status: "failed", reason }; } function timelineEditRefusal( diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts index a9279e709d..a579a28d19 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts @@ -452,6 +452,7 @@ describe("persistElementAttribute", () => { writeProjectFile, recordEdit: vi.fn(), pendingTimelineEditPathRef: { current: new Set() }, + onFileRead: vi.fn(), patchLive, }), ).rejects.toThrow("Unable to patch element in index.html"); @@ -495,6 +496,7 @@ describe("persistElementAttribute — unwind value", () => { writeProjectFile, recordEdit: vi.fn(), pendingTimelineEditPathRef: { current: new Set() }, + onFileRead: vi.fn(), // The live DOM is ALREADY at the new value when the commit runs — that // is what `setLive` does on every drag frame. patchLive: (v) => patched.push(v), diff --git a/packages/studio/src/hooks/timelineEditingHelpers.ts b/packages/studio/src/hooks/timelineEditingHelpers.ts index 95f612ec8c..328f7bc996 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.ts @@ -15,7 +15,12 @@ import { furthestClipEndFromSource, getTimelineElementIdentity, } from "../player/lib/timelineElementHelpers"; -import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory"; +import { + saveProjectFilesWithHistory, + writeProjectFilesWithHistoryInQueue, + type RecordEditInput, +} from "../utils/studioFileHistory"; +import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator"; import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes"; import { setCompositionDurationToContent } from "../utils/timelineAssetDrop"; import { readFileContent } from "./timelineTimingSync"; @@ -396,6 +401,31 @@ export async function persistTimelineBatchEdit( }); } +/** What the file holds for `attr` once queued writes to it land; undefined when unreadable. */ +export async function readSavedAttribute( + projectId: string | null, + targetPath: string, + patchTarget: PatchTarget | null, + attr: string, + writeFile: (path: string, content: string, expectedContent?: string) => Promise, +): Promise { + if (!projectId || !patchTarget) return undefined; + const html = await serializeStudioFileMutations(writeFile, [targetPath], () => + readFileContent(projectId, targetPath), + ).catch(() => null); + return html === null ? undefined : readTargetAttribute(html, patchTarget, attr); +} + +/** What `html` holds for `attr` on the target; undefined when the target is not in it. */ +export function readTargetAttribute( + html: string, + patchTarget: PatchTarget, + attr: string, +): string | null | undefined { + if (readTagSnippetByTarget(html, patchTarget) === undefined) return undefined; + return readAttributeByTarget(html, patchTarget, attr) ?? null; +} + export { applyPatchByTarget, formatTimelineAttributeNumber }; export { patchDocumentRootDuration } from "./timelineEditingGsap"; @@ -412,6 +442,8 @@ export interface PersistElementAttributeInput { pendingTimelineEditPathRef: { current: Set }; /** Write the attribute directly on the live preview DOM node. */ patchLive: (value: string | null) => void; + /** What the file held for the attribute, read inside the queue before this write. */ + onFileRead: (value: string | null) => void; } /** @@ -433,47 +465,40 @@ export async function persistElementAttribute({ recordEdit, pendingTimelineEditPathRef, patchLive, + onFileRead, }: PersistElementAttributeInput): Promise { - // Resolve the target BEFORE patching the live DOM. The optimistic patch used - // to run first, and only the save was wrapped in the unwind — so an - // unresolvable target threw with the live preview (and, through the callers' - // catch, the store mirrored off it) holding a value that never reached disk. - // The write then read as successful until a reload dropped it. - const before = await readFileContent(projectId, targetPath); - if (readTagSnippetByTarget(before, patchTarget) === undefined) { - throw new Error(`Unable to patch element in ${targetPath}`); - } - // The unwind value comes from the FILE, not from `readLive()`. - // - // Every live-write caller patches the DOM before committing — a fader drag is - // `setLive` per frame, hovering a preset auditions the whole chain — so by the - // time this runs the live DOM already holds the in-progress value. Reading it - // here made `previousValue === value`, so the unwind below was a no-op, and - // `setQuiet`'s catch (which deliberately re-mirrors the store from the live - // DOM) then mirrored that same never-saved value. The group audibly had the - // preset, the panel agreed, and a reload dropped it — the failure class the - // target check above was added to close, still open on the live-write path. - const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null; - patchLive(value); + // Joins the file's mutation queue before reading, so saves land in the order they start + // and each patches what the save before it wrote. Resolve the target before patching + // the live DOM, so an unresolvable target never leaves an unsaved preview. + return serializeStudioFileMutations(writeProjectFile, [targetPath], async () => { + const before = await readFileContent(projectId, targetPath); + if (readTagSnippetByTarget(before, patchTarget) === undefined) { + throw new Error(`Unable to patch element in ${targetPath}`); + } + // Unwind to the file's value: live writers already patched the DOM, so `readLive()` + // would equal `value` and a failed save would keep a never-saved preview. + const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null; + onFileRead(previousValue); + patchLive(value); - const operation: PatchOperation = { type: "attribute", property: attr, value }; - const patched = applyPatchByTarget(before, patchTarget, operation); + const operation: PatchOperation = { type: "attribute", property: attr, value }; + const patched = applyPatchByTarget(before, patchTarget, operation); - pendingTimelineEditPathRef.current.add(targetPath); - try { - const changedPaths = await saveProjectFilesWithHistory({ - projectId, - label, - files: { [targetPath]: patched }, - readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)), - writeFile: writeProjectFile, - recordEdit, - }); - return changedPaths; - } catch (error) { - // The optimistic live write already ran; unwind it on a save failure so - // the preview doesn't show a value that never reached disk. - patchLive(previousValue); - throw error; - } + pendingTimelineEditPathRef.current.add(targetPath); + try { + return await writeProjectFilesWithHistoryInQueue({ + projectId, + label, + files: { [targetPath]: patched }, + readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)), + writeFile: writeProjectFile, + recordEdit, + }); + } catch (error) { + // The optimistic live write already ran; unwind it on a save failure so + // the preview doesn't show a value that never reached disk. + patchLive(previousValue); + throw error; + } + }); } diff --git a/packages/studio/src/hooks/timelineElementFxAttribute.ts b/packages/studio/src/hooks/timelineElementFxAttribute.ts index 43db8014c9..a563d7ae05 100644 --- a/packages/studio/src/hooks/timelineElementFxAttribute.ts +++ b/packages/studio/src/hooks/timelineElementFxAttribute.ts @@ -7,18 +7,25 @@ * shared core `setAudioGroupAttribute` also uses. */ -import { useCallback, useRef } from "react"; +import { useCallback } from "react"; import type { TimelineElement } from "../player"; import { buildPatchTarget, findTimelineElementInIframe, persistElementAttribute, + readSavedAttribute, } from "./timelineEditingHelpers"; +import { useLiveLanes, type LiveLaneRestore, type LiveLaneSource } from "./liveLanes"; import type { MutableRef, UseTimelineElementVisibilityEditingInput, } from "./timelineTrackVisibility"; -import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission"; +import { + failedTimelineSave, + projectForTimelineSave, + type TimelineEditOutcome, +} from "./timelineEditPermission"; +import { syncStoredElementAttribute } from "../player/lib/automationStoreSync"; function patchLiveElementAttribute( iframe: HTMLIFrameElement | null, @@ -41,6 +48,22 @@ function elementAttributeLiveKey( return `${element.sourceFile || activeCompPath || "index.html"}\0${element.key ?? element.domId ?? element.id}\0${attr}`; } +function elementSaveTarget(element: TimelineElement, activeCompPath: string | null) { + return { + targetPath: element.sourceFile || activeCompPath || "index.html", + patchTarget: buildPatchTarget(element), + }; +} + +function elementLiveSource( + element: TimelineElement, + activeCompPath: string | null, + attr: string, +): LiveLaneSource { + const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath); + return { path: targetPath, target: patchTarget, attr }; +} + interface SetElementAttributeInput { projectId: string; activeCompPath: string | null; @@ -48,7 +71,8 @@ interface SetElementAttributeInput { attr: string; value: string | null; label: string; - previewIframe: HTMLIFrameElement | null; + patchLive: (value: string | null) => void; + onFileRead: (value: string | null) => void; writeProjectFile: (path: string, content: string) => Promise; recordEdit: Parameters[0]["recordEdit"]; pendingTimelineEditPathRef: MutableRef>; @@ -61,13 +85,13 @@ async function setElementAttribute({ attr, value, label, - previewIframe, + patchLive, + onFileRead, writeProjectFile, recordEdit, pendingTimelineEditPathRef, }: SetElementAttributeInput): Promise { - const targetPath = element.sourceFile || activeCompPath || "index.html"; - const patchTarget = buildPatchTarget(element); + const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath); if (!patchTarget) return null; return persistElementAttribute({ @@ -80,7 +104,8 @@ async function setElementAttribute({ writeProjectFile, recordEdit, pendingTimelineEditPathRef, - patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath), + patchLive, + onFileRead, }); } @@ -102,33 +127,46 @@ export function useSetElementAttribute({ label: string, ) => Promise; revertLive: (element: TimelineElement, attr: string) => void; + restoreLive: (restore: LiveLaneRestore) => void; } { - const liveBeforeRef = useRef(new Map()); + const liveLanes = useLiveLanes(projectIdRef, activeCompPath); const setLive = useCallback( (element: TimelineElement, attr: string, value: string | null) => { const key = elementAttributeLiveKey(element, activeCompPath, attr); const target = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath); - if (!liveBeforeRef.current.has(key)) { - liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null); - } + liveLanes.preview( + key, + () => target?.getAttribute(attr) ?? null, + elementLiveSource(element, activeCompPath, attr), + ); patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath); }, + [liveLanes, previewIframeRef, activeCompPath], + ); + const laneApply = useCallback( + (element: TimelineElement, attr: string) => ({ + preview: (value: string | null) => + patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath), + store: (value: string | null) => syncStoredElementAttribute(element, attr, value), + }), [previewIframeRef, activeCompPath], ); + const claimLive = useCallback( + (element: TimelineElement, attr: string) => + liveLanes.claim( + elementAttributeLiveKey(element, activeCompPath, attr), + laneApply(element, attr), + elementLiveSource(element, activeCompPath, attr), + ), + [liveLanes, laneApply, activeCompPath], + ); const revertLive = useCallback( - (element: TimelineElement, attr: string) => { - const key = elementAttributeLiveKey(element, activeCompPath, attr); - if (!liveBeforeRef.current.has(key)) return; - patchLiveElementAttribute( - previewIframeRef.current, - element, - attr, - liveBeforeRef.current.get(key) ?? null, - activeCompPath, - ); - liveBeforeRef.current.delete(key); - }, - [previewIframeRef, activeCompPath], + (element: TimelineElement, attr: string) => + liveLanes.revert( + elementAttributeLiveKey(element, activeCompPath, attr), + laneApply(element, attr), + ), + [liveLanes, laneApply, activeCompPath], ); const setQuiet = useCallback( async ( @@ -137,9 +175,17 @@ export function useSetElementAttribute({ value: string | null, label: string, ): Promise => { - const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast); - if (typeof pid !== "string") return pid; - const liveKey = elementAttributeLiveKey(element, activeCompPath, attr); + const project = projectIdRef.current; + const pid = projectForTimelineSave(isRecordingRef?.current, project, showToast); + const live = claimLive(element, attr); + const unsaved = async (outcome: TimelineEditOutcome): Promise => { + const { targetPath, patchTarget } = elementSaveTarget(element, activeCompPath); + live.settle( + await readSavedAttribute(project, targetPath, patchTarget, attr, writeProjectFile), + ); + return outcome; + }; + if (typeof pid !== "string") return unsaved(pid); try { const written = await setElementAttribute({ projectId: pid, @@ -148,25 +194,25 @@ export function useSetElementAttribute({ attr, value, label, - previewIframe: previewIframeRef.current, + patchLive: live.preview, + onFileRead: live.read, writeProjectFile, recordEdit, pendingTimelineEditPathRef, }); - liveBeforeRef.current.delete(liveKey); - if (written) return { status: "saved" }; - return { status: "failed", reason: "This clip has no id to save it by" }; + if (!written) + return unsaved(failedTimelineSave("This clip has no id to save it by", showToast)); + live.settle(value); + return { status: "saved" }; } catch (error) { console.error("[Timeline] Failed to set element attribute", error); const message = error instanceof Error ? error.message : "Failed to update effect"; - showToast(message); - liveBeforeRef.current.delete(liveKey); - return { status: "failed", reason: message }; + return unsaved(failedTimelineSave(message, showToast)); } }, [ + claimLive, activeCompPath, - previewIframeRef, writeProjectFile, recordEdit, pendingTimelineEditPathRef, @@ -175,5 +221,5 @@ export function useSetElementAttribute({ projectIdRef, ], ); - return { setLive, setQuiet, revertLive }; + return { setLive, setQuiet, revertLive, restoreLive: liveLanes.restore }; } diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 49b60d8f13..47fa2260ef 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -93,7 +93,7 @@ interface UseAppHotkeysParams { handleDuplicate: () => Promise; onResetKeyframes: () => boolean; onDeleteSelectedKeyframes: () => void; - onAfterUndoRedo?: () => void; + onAfterUndoRedo?: UseEditHistoryActionsOptions["onAfterUndoRedo"]; onToggleRecording?: () => void; /** Group the current multi-selection into a data-hf-group wrapper (⌘G). */ onGroupSelection?: () => void; diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx index 7dc02f6f89..7829e5e8b1 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.test.tsx @@ -77,7 +77,7 @@ describe("useAutomationSelectionKeyboard", () => { }); const setup = (binding: Partial) => { - const onCommit = vi.fn(); + const onCommit = vi.fn().mockResolvedValue(undefined); const automation = { version: 1, lanes: [ @@ -102,7 +102,6 @@ describe("useAutomationSelectionKeyboard", () => { onCommit, onSelect: vi.fn(), readOnly: false, - commitTargetKey: "bgm", selection: null, onRangeSelect: vi.fn(), onRangeClear: vi.fn(), @@ -230,7 +229,7 @@ describe("useAutomationSelectionKeyboard", () => { expect(entry?.points.map((p) => p.t)).toEqual([0, 2]); }); - it("Cmd+V with no selection pastes at the playhead and selects the pasted span", () => { + it("Cmd+V with no selection pastes at the playhead and selects the pasted span", async () => { clearAutomationClipboard(); // Duration wide enough that the playhead (5s) is not clamped down by the // 0..duration-span bound — this is a paste-at-playhead test, not a @@ -255,6 +254,7 @@ describe("useAutomationSelectionKeyboard", () => { expect(times).toContain(7); // + clipboard span 2 // Pasting again immediately should land right after the first paste. + await act(async () => {}); expect(usePlayerStore.getState().automationSelection).toEqual({ elementKey: "bgm", target: "volume", @@ -268,7 +268,124 @@ describe("useAutomationSelectionKeyboard", () => { }); }); - it("chains a second Cmd+V after the first instead of overwriting it", () => { + it("marks nothing when the paste's save is refused, even for two quick presses", async () => { + clearAutomationClipboard(); + usePlayerStore.setState({ + elements: [{ ...bgmElement, duration: 10 }], + selectedElementId: "bgm", + }); + const original = wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 }); + usePlayerStore.getState().setAutomationSelection(original); + const onCommit = vi.fn().mockResolvedValue({ status: "refused", reason: "Locked" }); + setup({ onCommit }); + combo("c"); + combo("v"); + combo("v"); + await act(async () => {}); + expect(usePlayerStore.getState().automationSelection).toEqual(original); + combo("v"); + const again = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map( + (p: { t: number }) => p.t, + ); + expect(again).toContain(2); + expect(again).not.toContain(6); + }); + + it.each([{ status: "saved" as const }, { status: "refused" as const, reason: "Locked" }])( + "keeps a selection drawn while a paste was saving ($status)", + async (outcome) => { + clearAutomationClipboard(); + usePlayerStore.setState({ + elements: [{ ...bgmElement, duration: 10 }], + selectedElementId: "bgm", + }); + usePlayerStore + .getState() + .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 })); + let land = () => {}; + const onCommit = vi.fn( + () => + new Promise((resolve) => { + land = () => resolve(outcome); + }), + ); + setup({ onCommit }); + combo("c"); + combo("v"); + const drawn = wholeAxis({ elementKey: "bgm", target: "volume", t0: 6, t1: 8 }); + usePlayerStore.getState().setAutomationSelection(drawn); + land(); + await act(async () => {}); + expect(usePlayerStore.getState().automationSelection).toEqual(drawn); + }, + ); + + it.each(["in order", "reversed"])( + "marks the latest paste when pastes on two clips resolve %s", + async (order) => { + clearAutomationClipboard(); + usePlayerStore.setState({ + elements: [ + { ...bgmElement, duration: 10 }, + { ...bgmElement, id: "vo", key: "vo", duration: 10 }, + ], + selectedElementId: "bgm", + currentTime: 1, + }); + usePlayerStore + .getState() + .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 })); + const lands: Array<() => void> = []; + const onCommit = vi.fn( + () => + new Promise<{ status: "saved" }>((resolve) => + lands.push(() => resolve({ status: "saved" })), + ), + ); + setup({ onCommit }); + combo("c"); + usePlayerStore.getState().clearAutomationSelection(); + combo("v"); + usePlayerStore.setState({ selectedElementId: "vo" }); + combo("v"); + const order_ = order === "in order" ? [0, 1] : [1, 0]; + for (const i of order_) lands[i]?.(); + await act(async () => {}); + expect(usePlayerStore.getState().automationSelection?.elementKey).toBe("vo"); + }, + ); + + it("does not mark a paste whose clip was left before it landed", async () => { + clearAutomationClipboard(); + usePlayerStore.setState({ + elements: [ + { ...bgmElement, duration: 10 }, + { ...bgmElement, id: "vo", key: "vo", duration: 10 }, + ], + selectedElementId: "bgm", + currentTime: 1, + }); + usePlayerStore + .getState() + .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 })); + let land = () => {}; + const onCommit = vi.fn( + () => + new Promise<{ status: "saved" }>((resolve) => { + land = () => resolve({ status: "saved" }); + }), + ); + setup({ onCommit }); + combo("c"); + usePlayerStore.getState().clearAutomationSelection(); + combo("v"); + usePlayerStore.setState({ selectedElementId: "vo" }); + land(); + await act(async () => {}); + expect(usePlayerStore.getState().automationSelection).toBeNull(); + }); + + it("chains a second Cmd+V after the first instead of overwriting it", async () => { // The regression this pins: paste leaves its own span selected, so anchoring // at sel.t0 unconditionally made every later press recompute the same atT. clearAutomationClipboard(); @@ -288,6 +405,7 @@ describe("useAutomationSelectionKeyboard", () => { ); expect(first).toContain(2); expect(first).toContain(4); + await act(async () => {}); combo("v"); const second = (onCommit.mock.calls.at(-1)?.[0]?.lanes?.[0]?.points ?? []).map( @@ -295,6 +413,7 @@ describe("useAutomationSelectionKeyboard", () => { ); expect(second).toContain(4); expect(second).toContain(6); + await act(async () => {}); expect(usePlayerStore.getState().automationSelection).toEqual({ elementKey: "bgm", target: "volume", @@ -308,7 +427,7 @@ describe("useAutomationSelectionKeyboard", () => { }); }); - it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", () => { + it("Cmd+V at a selection near the clip's end clamps the paste inside its duration", async () => { // The playhead branch already clamps to duration - span; the // selection-start branch didn't, so pasting a 2s clip at a selection // sitting at t0=5.5 on a 6s clip used to write points out to t=7.5 — @@ -334,6 +453,7 @@ describe("useAutomationSelectionKeyboard", () => { expect(t).toBeLessThanOrEqual(bgmElement.duration); } // Clamped to duration (6) - span (2) = 4, not the unclamped 5.5. + await act(async () => {}); expect(usePlayerStore.getState().automationSelection).toEqual({ elementKey: "bgm", target: "volume", @@ -347,22 +467,6 @@ describe("useAutomationSelectionKeyboard", () => { }); }); - it("refuses to paste when the dom-edit layer would write to a different clip", () => { - // selectedElementId says "bgm" but the commit channel is still on the - // previously selected clip — writing here would serialize bgm's automation - // onto that other clip and leave bgm untouched. - clearAutomationClipboard(); - copyRange(null, { target: "volume", points: [{ t: 0, v: 0.5 }] }, VOLUME_RANGE, 0, 2); - usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" }); - usePlayerStore - .getState() - .setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 2, t1: 4 })); - const { onCommit } = setup({ commitTargetKey: "some-other-clip" }); - const e = combo("v"); - expect(e.defaultPrevented).toBe(false); - expect(onCommit).not.toHaveBeenCalled(); - }); - it("does not paste from a playhead outside the clip", () => { // No selection on this clip, and the playhead is past its end — there is no // anchor. This used to collapse to the clip's own t=0. diff --git a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts index 5e73ab52f8..8e79d415a5 100644 --- a/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts +++ b/packages/studio/src/hooks/useAutomationSelectionKeyboard.ts @@ -108,7 +108,7 @@ function resolveDeleteWrite( state: PlayerState, lanes: UseAutomationLanesResult, sel: AutomationSelection, -): { onCommit(next: HfAutomation): void; next: HfAutomation } | null { +): { onCommit: AutomationLaneBinding["onCommit"]; next: HfAutomation } | null { const ctx = resolveSelectionContext(state, lanes, sel); if (!ctx) return null; const points = ctx.lane.points.filter((p) => !pointInSelection(p, sel)); @@ -139,18 +139,7 @@ function pasteTargetName( /** * Where Cmd+V lands, or null when nothing is selected, the clip's lanes are - * read-only, it has no automation lane to fall back to, or the dom-edit layer - * would write the result to a DIFFERENT clip. - * - * That last guard is the one with teeth. `binding.onCommit` persists through - * handleDomAttributeQuietCommit, which targets whatever the dom-edit layer - * currently has selected — not the element `bind()` was handed (see the - * doc-comment on `onSelect` in useAutomationLanes). Selecting a clip in the - * timeline sets `selectedElementId` synchronously but resolves the dom-edit - * selection asynchronously, so clicking clip B and immediately pressing Cmd+V - * would serialize B's automation onto A. Every other lane path is a pointer - * gesture on the lane itself, which cannot run before the selection lands; - * paste is the only one that can, so it refuses rather than write blind. + * read-only, or it has no automation lane to fall back to. */ function resolvePasteTarget( state: PlayerState, @@ -169,7 +158,6 @@ function resolvePasteTarget( const elementKey = elementKeyOf(element); const binding = lanes.bind(element, true); if (binding.readOnly) return null; - if (binding.commitTargetKey !== elementKey) return null; const target = pasteTargetName(binding, elementKey, sel); if (!target) return null; const range = resolveAutomationRange(target, binding.chain ?? undefined); @@ -225,6 +213,8 @@ function pasteAnchor( return clampNumber(raw, 0, element.duration - span); } +let latestPaste = 0; + /** * Cmd/Ctrl+V: paste the clipboard onto the selected clip's lane, at the active * selection or the playhead. Returns false (untouched event) when the chord @@ -252,12 +242,10 @@ function handlePaste( e.preventDefault(); e.stopImmediatePropagation(); - paste.binding.onCommit(withLane(paste.binding.automation, { target: paste.target, points })); - // Select the pasted span — the only feedback that it landed — and mark it, so - // an immediate second Cmd+V recognises this selection as the paste's own and - // chains right after it instead of overwriting it. - // Full-height box over the pasted span: everything that landed is selected, so - // Delete straight after a paste undoes it in one press. + const saved = paste.binding.onCommit( + withLane(paste.binding.automation, { target: paste.target, points }), + ); + const seq = ++latestPaste; const mark = { elementKey: paste.elementKey, target: paste.target, @@ -266,8 +254,17 @@ function handlePaste( v0: paste.range.min, v1: paste.range.max, }; - state.setAutomationSelection(mark); - markLastPaste(mark); + // Once the latest paste lands, and only over the selection it was pasted at, select + // and mark its full-height span: the feedback that it landed, what a second Cmd+V + // chains after, and what Delete takes back in one press. + void saved.then((outcome) => { + if (outcome && outcome.status !== "saved") return; + const current = usePlayerStore.getState(); + const moved = current.selectedElementId !== state.selectedElementId; + if (seq !== latestPaste || moved || current.automationSelection !== sel) return; + current.setAutomationSelection(mark); + markLastPaste(mark); + }); return true; } @@ -300,8 +297,7 @@ function handleCopy( * * Store and clipboard only, no lane binding, so the dispatcher can call it * without holding the binding factory. That leaves one accepted residual: the - * predicate cannot see a read-only lane, an unresolvable target, or the - * dom-edit target mismatch `resolvePasteTarget` guards, so in those rare cases + * predicate cannot see a read-only lane or an unresolvable target, so in those rare cases * the keystroke is a no-op instead of falling through to clip copy/paste. A * dead key beats today's double write. */ @@ -333,7 +329,7 @@ function handleDelete( if (!write) return false; e.preventDefault(); e.stopImmediatePropagation(); - write.onCommit(write.next); + void write.onCommit(write.next); return true; } diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.ts index d420185bd5..cf1ea19cab 100644 --- a/packages/studio/src/hooks/useDomEditAttributeCommits.ts +++ b/packages/studio/src/hooks/useDomEditAttributeCommits.ts @@ -29,15 +29,6 @@ interface DataAttributeCommitOptions { skipRefresh: boolean; refreshAfter?: boolean; onSettled?: (ok: boolean) => void; - /** - * Undo grouping for a gesture that spans several commits. - * - * Without it the key is derived from the prefix, attribute and element, and the - * window is history's own 300ms — so a drag's moves and the release that ends it - * landed in different entries, and a drag slower than the window split further. - * A caller that knows a gesture is in progress passes one key for all of it. - */ - coalesce?: { key: string; ms: number }; /** * Apply to the preview and stop there — no file write, no history entry. * @@ -150,9 +141,7 @@ export function useDomEditAttributeCommits({ if (!domEditSelection) return; const iframe = previewIframeRef.current; const fullAttr = resolveFullAttrName(attr, true); - const commitKey = - options.coalesce?.key ?? - `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`; + const commitKey = `${options.coalescePrefix}:${attr}:${getDomEditTargetKey(domEditSelection)}`; const isLatestCommit = bumpDomEditCommitMapVersion( domAttributeCommitVersionRef.current, commitKey, @@ -184,7 +173,6 @@ export function useDomEditAttributeCommits({ persistDomEditOperations(domEditSelection, [op], { label: options.label, coalesceKey: commitKey, - ...(options.coalesce ? { coalesceMs: options.coalesce.ms } : {}), skipRefresh: options.skipRefresh, }), shouldRevert: () => isLatestCommit(), @@ -338,14 +326,13 @@ export function useDomEditAttributeCommits({ attr: string, value: string | null, onSettled?: (ok: boolean) => void, - live?: { coalesce?: { key: string; ms: number }; previewOnly?: boolean }, + live?: { previewOnly?: boolean }, ) => { await commitDataAttribute(attr, value, { label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`, coalescePrefix: "attr-live", skipRefresh: true, onSettled, - ...(live?.coalesce ? { coalesce: live.coalesce } : {}), ...(live?.previewOnly ? { previewOnly: true } : {}), }); }, @@ -362,13 +349,12 @@ export function useDomEditAttributeCommits({ * edit computes from a pre-edit value and appears to do nothing. */ const handleDomAttributeQuietCommit = useCallback( - async (attr: string, value: string | null, coalesce?: { key: string; ms: number }) => { + async (attr: string, value: string | null) => { await commitDataAttribute(attr, value, { label: `Edit ${attr.replace(/^(data-)?/, "").replace(/-/g, " ")}`, coalescePrefix: "attr-quiet", skipRefresh: true, refreshAfter: true, - ...(coalesce ? { coalesce } : {}), }); }, [commitDataAttribute], diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index dfdfb99c89..69485ddf2c 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -511,6 +511,7 @@ export function useDomEditSession({ handlePreviewCanvasPointerLeave, applyDomSelection, clearDomSelection, + refreshDomEditSelectionFromPreview, handleDomStyleCommit, handleDomStyleCommitForSelection, handleDomAttributeCommit, diff --git a/packages/studio/src/hooks/useEditHistoryActions.ts b/packages/studio/src/hooks/useEditHistoryActions.ts index dab2ebad7f..d54051f67c 100644 --- a/packages/studio/src/hooks/useEditHistoryActions.ts +++ b/packages/studio/src/hooks/useEditHistoryActions.ts @@ -33,7 +33,7 @@ export interface UseEditHistoryActionsOptions { showToast: (message: string, tone?: "error" | "info") => void; syncHistoryPreviewAfterApply: (restore: Pick) => Promise; waitForPendingDomEditSaves: () => Promise; - onAfterUndoRedo?: () => void; + onAfterUndoRedo?: (restore: Pick) => void; /** Active composition path — decides whether undo/redo must resync the SDK session. */ activeCompPath?: string | null; /** Reloads the SDK session after a revert of the active comp, past the self-write suppress window. */ @@ -84,11 +84,12 @@ export function useEditHistoryActions({ return; } if (result.ok && result.label) { - onAfterUndoRedo?.(); + const restore = { paths: result.paths, files: result.files }; + onAfterUndoRedo?.(restore); if (activeCompPath && result.paths?.includes(activeCompPath)) { forceReloadSdkSession?.(); } - await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files }); + await syncHistoryPreviewAfterApply(restore); showToast(result.label, "info"); } }, diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 16de9450b6..e15cae8ce8 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -436,7 +436,7 @@ export function useTimelineEditing({ checkEditable, }); - const { revertLive: revertElementFxLive, ...setElementFxAttribute } = useSetElementAttribute({ + const setElementFxAttribute = useSetElementAttribute({ projectIdRef, activeCompPath, showToast, @@ -447,18 +447,16 @@ export function useTimelineEditing({ isRecordingRef, }); - const { revertLive: revertAudioGroupLive, ...setAudioGroupAttribute } = useSetAudioGroupAttribute( - { - projectIdRef, - activeCompPath, - showToast, - writeProjectFile, - recordEdit, - previewIframeRef, - pendingTimelineEditPathRef, - isRecordingRef, - }, - ); + const setAudioGroupAttribute = useSetAudioGroupAttribute({ + projectIdRef, + activeCompPath, + showToast, + writeProjectFile, + recordEdit, + previewIframeRef, + pendingTimelineEditPathRef, + isRecordingRef, + }); const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({ projectIdRef, @@ -550,7 +548,7 @@ export function useTimelineEditing({ // no flat twin, only a domClipChildren entry, so both are checked. setQuiet: track( guard(audioGroupMembers, setAudioGroupAttribute.setQuiet, (reason, groupId, attr) => { - revertAudioGroupLive(groupId, attr); + setAudioGroupAttribute.revertLive(groupId, attr); return refused(reason); }), ), @@ -562,7 +560,7 @@ export function useTimelineEditing({ (element) => [element], setElementFxAttribute.setQuiet, (reason, element, attr) => { - revertElementFxLive(element, attr); + setElementFxAttribute.revertLive(element, attr); return refused(reason); }, ), @@ -591,5 +589,9 @@ export function useTimelineEditing({ handleTimelineGroupResize: track( guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupResize), ), + restoreLiveLanes: (restore: Parameters[0]) => { + setElementFxAttribute.restoreLive(restore); + setAudioGroupAttribute.restoreLive(restore); + }, }; } diff --git a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx index 96e1e43232..a764c881e4 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.test.tsx @@ -338,6 +338,24 @@ describe("TimelineAutomationLane", () => { ); }); + it("drops the dragged position when the save does not land", async () => { + const onCommit = vi.fn(async () => ({ status: "refused" as const, reason: "Locked" })); + const { container } = render( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + const before = Number(container.querySelectorAll("circle")[0]!.getAttribute("cx")); + fire(svg, "pointerdown", { clientX: 0, clientY: 6 }); + fire(svg, "pointermove", { clientX: 160, clientY: 40 }); + fire(svg, "pointerup", { clientX: 160, clientY: 40 }); + await act(async () => {}); + expect(Number(container.querySelectorAll("circle")[0]!.getAttribute("cx"))).toBeCloseTo( + before, + 5, + ); + }); + it("follows the prop again once the store catches up", () => { const { container, rerender } = renderRerenderable( , @@ -359,6 +377,25 @@ describe("TimelineAutomationLane", () => { expect(Number(circles[0]!.getAttribute("cx"))).toBeCloseTo(PAD + 300, 0); }); + it("commits the dragged points when an older save lands mid-drag", () => { + const onCommit = vi.fn(async (_next: HfAutomation) => ({ status: "saved" as const })); + const { container, rerender } = renderRerenderable( + , + ); + const svg = container.querySelector("svg")!; + stubBox(svg, { left: 0, top: 0, width: 400, height: 48 }); + fire(svg, "pointerdown", { clientX: 0, clientY: 6 }); + fire(svg, "pointermove", { clientX: 160, clientY: 40 }); + fire(svg, "pointerup", { clientX: 160, clientY: 40 }); + const older = onCommit.mock.calls[0]![0]; + fire(svg, "pointerdown", { clientX: 160, clientY: 40 }); + fire(svg, "pointermove", { clientX: 320, clientY: 40 }); + rerender(); + fire(svg, "pointerup", { clientX: 320, clientY: 40 }); + const released = onCommit.mock.calls[1]![0]; + expect(released.lanes[0]!.points[0]!.t).toBeGreaterThan(older.lanes[0]!.points[0]!.t + 1); + }); + it("keeps lane order when editing, so the view does not switch parameters", () => { // The displayed lane defaults to the first one. Moving the edited lane to // the end of the list swapped the lane out from under the pointer on the @@ -1627,6 +1664,7 @@ describe("TimelineAutomationLane stretch", () => { const reverted = (props.onPreview.mock.calls.at(-1)?.[0] as HfAutomation | undefined)?.lanes[0] ?.points; expect(reverted).toEqual(stretchable.lanes[0]?.points); + expect(props.onPreview.mock.calls.at(-1)?.[1]).toBe(true); expect(onRangeSelect).toHaveBeenLastCalledWith(0.5, 2.5, 0, 1); }); diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index 00a1ee2a7b..83d1af58c4 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -27,6 +27,7 @@ import { type HfAutomationLane, type HfAutomationPoint, } from "@hyperframes/core/audio-automation"; +import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission"; import { envelopePath, fromUnit, laneFor, PAD_X, toUnit, withLane } from "./automationLaneGeometry"; import { useAutomationLaneGestures } from "./useAutomationLaneGestures"; import { AutomationValueInput } from "./AutomationValueInput"; @@ -180,9 +181,9 @@ export interface TimelineAutomationLaneProps { /** Clip-local seconds of the playhead, or null when it is outside the clip. */ playheadSec: number | null; /** Continuous write while dragging; does not persist. */ - onPreview(automation: HfAutomation): void; + onPreview(automation: HfAutomation, ended?: boolean): void; /** Gesture-end write; this is the one that persists and lands in undo. */ - onCommit(automation: HfAutomation): void; + onCommit(automation: HfAutomation): Promise | void; /** * Clip-local times a dragged point snaps to — the beat grid, shifted into this * clip's frame. Its own neighbouring points are added on top. @@ -241,14 +242,6 @@ export function TimelineAutomationLane({ [draft, target, stored], ); - // The draft is released when the automation it was drawn over actually - // changes — the persisted edit landing, or an edit from elsewhere. Releasing - // it merely because the drag ended would snap the point back to where it - // started for as long as the write takes to come around. - useEffect(() => { - if (draft && draft.basedOn !== automation) setDraft(null); - }, [automation, draft]); - // A different parameter is a different envelope; the draft does not carry over. useEffect(() => { setDraft(null); @@ -289,12 +282,16 @@ export function TimelineAutomationLane({ ); const commitPoints = useCallback( - (points: HfAutomationLane["points"], persist: boolean): void => { + (points: HfAutomationLane["points"], persist: boolean, ended?: boolean): void => { // Draw from the draft immediately; the write is what eventually agrees. setDraft({ points, basedOn: automation }); const next = withLane(automation, { target, points }); - if (persist) onCommit(next); - else onPreview(next); + if (!persist) return onPreview(next, ended); + void Promise.resolve(onCommit(next)).then((outcome) => { + if (outcome && outcome.status !== "saved") { + setDraft((current) => (current?.points === points ? null : current)); + } + }); }, [automation, target, onCommit, onPreview], ); @@ -319,6 +316,7 @@ export function TimelineAutomationLane({ duration, rangeSelection, }); + const { dragIndex, curveIndex, @@ -329,6 +327,13 @@ export function TimelineAutomationLane({ hint, editing, } = gestures; + // Released when the automation it was drawn over changes, not on drag end (the point would + // snap back until the write lands) and not under a live gesture (its release would commit + // an older save's points). + const gestureLive = [dragIndex, curveIndex, segmentDragIndex, edgeDrag].some((g) => g !== null); + useEffect(() => { + if (draft && draft.basedOn !== automation && !gestureLive) setDraft(null); + }, [automation, draft, gestureLive]); const removeAt = useCallback( (index: number): void => { diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx index 7249132f57..29740c2c2f 100644 --- a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx @@ -32,7 +32,6 @@ function mountSlot(binding: Partial) { onCommit: vi.fn(), onSelect: vi.fn(), readOnly: false, - commitTargetKey: "bgm", selection: null, onRangeSelect: vi.fn(), onRangeClear, @@ -96,7 +95,6 @@ const readingBind = (element: TimelineElement, isSelected: boolean): AutomationL onCommit: vi.fn(), onSelect: vi.fn(), readOnly: !isSelected, - commitTargetKey: null, selection: null, onRangeSelect: vi.fn(), onRangeClear: vi.fn(), diff --git a/packages/studio/src/player/components/TimelineClipFades.test.tsx b/packages/studio/src/player/components/TimelineClipFades.test.tsx index 29547513ea..1d36adab2e 100644 --- a/packages/studio/src/player/components/TimelineClipFades.test.tsx +++ b/packages/studio/src/player/components/TimelineClipFades.test.tsx @@ -33,6 +33,7 @@ function render( const root = createRoot(host); const onSetElementAttributeLive = vi.fn(); const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined); + const onRevertElementAttributeLive = vi.fn(); // Stands in for the TimelineClip button the handles live inside. const node = (
@@ -49,13 +50,25 @@ function render( options.provide === false ? ( node ) : ( - + {node} ), ); }); - return { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet }; + return { + host, + root, + onSetElementAttributeLive, + onSetElementAttributeQuiet, + onRevertElementAttributeLive, + }; } function pointer(type: string, clientX: number, pointerId = 1) { @@ -146,14 +159,22 @@ describe("TimelineClipFades", () => { }); it("puts the live value back and writes nothing when the gesture is cancelled", () => { - const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip); + const { + host, + root, + onSetElementAttributeLive, + onRevertElementAttributeLive, + onSetElementAttributeQuiet, + } = render(clip); const handle = host.querySelector('[data-testid="clip-fade-handle-in"]'); if (!handle) throw new Error("expected a fade-in handle"); armCapture(handle); act(() => handle.dispatchEvent(pointer("pointerdown", 100))); act(() => handle.dispatchEvent(pointer("pointermove", 300))); act(() => handle.dispatchEvent(pointer("pointercancel", 300))); + // Written back live for a host without the revert, then ended through the lanes' revert. expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1"); + expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in"); expect(onSetElementAttributeQuiet).not.toHaveBeenCalled(); act(() => root.unmount()); }); diff --git a/packages/studio/src/player/components/TimelineClipFades.tsx b/packages/studio/src/player/components/TimelineClipFades.tsx index 4e33d786c5..00ed3e40de 100644 --- a/packages/studio/src/player/components/TimelineClipFades.tsx +++ b/packages/studio/src/player/components/TimelineClipFades.tsx @@ -32,7 +32,7 @@ interface TimelineClipFadesProps { /** Corner dots that drag `data-fade-in` / `data-fade-out`; the wedge is the faded region. */ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineClipFadesProps) { - const { onSetElementAttributeLive, onSetElementAttributeQuiet } = + const { onSetElementAttributeLive, onSetElementAttributeQuiet, onRevertElementAttributeLive } = useTimelineEditContextOptional(); const canEdit = Boolean(onSetElementAttributeLive && onSetElementAttributeQuiet); @@ -126,7 +126,10 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli /** Puts the live document back where the file has it and drops the draft. */ const revertGesture = (g: Gesture) => { - if (g.moved) onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds)); + if (g.moved) { + onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds)); + onRevertElementAttributeLive?.(el, attrFor(g.edge)); + } setDraft(null); }; diff --git a/packages/studio/src/player/components/TimelineFxButton.tsx b/packages/studio/src/player/components/TimelineFxButton.tsx index f6e0ee894d..75af8ce56e 100644 --- a/packages/studio/src/player/components/TimelineFxButton.tsx +++ b/packages/studio/src/player/components/TimelineFxButton.tsx @@ -157,7 +157,7 @@ interface TimelineFxButtonChainProps { onOpenRack: () => void; fxChainRaw: string | undefined; onChainChange: (next: HfAudioFxChain) => void; - onChainPreview?: (next: HfAudioFxChain) => void; + onChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void; /** The clips this chain is heard through, so an audition starts where they * actually sound rather than from a playhead parked before the first. */ auditionSpans?: readonly AuditionSpan[]; diff --git a/packages/studio/src/player/components/TimelineGroupHeader.tsx b/packages/studio/src/player/components/TimelineGroupHeader.tsx index 9241d59109..5f7732c0ef 100644 --- a/packages/studio/src/player/components/TimelineGroupHeader.tsx +++ b/packages/studio/src/player/components/TimelineGroupHeader.tsx @@ -18,7 +18,7 @@ interface TimelineGroupHeaderProps { /** C1: the group's serialized `data-fx-chain`, when set. */ fxChain?: string; onFxChainChange: (next: HfAudioFxChain) => void; - onFxChainPreview?: (next: HfAudioFxChain) => void; + onFxChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void; /** Member clips, so hovering a preset auditions where the group sounds. */ auditionSpans?: readonly AuditionSpan[]; onOpenFxRack: () => void; diff --git a/packages/studio/src/player/components/TimelineGroupRow.test.tsx b/packages/studio/src/player/components/TimelineGroupRow.test.tsx index e72d54ba51..bf7542109b 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.test.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.test.tsx @@ -84,7 +84,6 @@ function renderRow( onCommit: vi.fn(), onSelect: vi.fn(), readOnly: true, - commitTargetKey: null, selection: null, onRangeSelect: vi.fn(), onRangeClear: vi.fn(), diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 615faf5011..192752e043 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -80,9 +80,8 @@ export function TimelineGroupRow({ // The group wearing a clip's shape so the lane machinery can render it — see // `groupAutomationElement` for why that beats a second, parallel lane path. const groupElement = groupAutomationElement(group, compositionDuration); - // The binder writes through the dom-edit selection, so a group lane is - // editable exactly when the group is the selected element — which clicking - // its name in the header does. + // A group lane is editable exactly when the group is the selected element, + // which clicking its name in the header does. const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null; const isGroupSelected = domSelection?.id === group.id; const isLaneOpen = expandedLaneOwnerIds.has(group.id); @@ -90,14 +89,21 @@ export function TimelineGroupRow({ // provider in read-only hosts (Timeline.test.ts asserts it), and the throwing // hook took the whole timeline down with it the moment a group existed — // not just this row. - const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = - useTimelineEditContextOptional(); + const { + onSetAudioGroupAttributeLive, + onSetAudioGroupAttributeQuiet, + onRevertAudioGroupAttributeLive, + } = useTimelineEditContextOptional(); const domEditActions = useDomEditActionsContextOptional(); const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget); - const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => { + const writeGroupFxChain = (next: HfAudioFxChain, live: boolean, ended = false) => { const value = next.nodes.length ? serializeAudioFxChain(next) : null; - if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value); - else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset"); + if (!live) { + void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset"); + return; + } + onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value); + if (ended) onRevertAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR); }; const openGroupFxRack = (automationTarget?: string) => { // Use the guarded timeline-selection path even though the bus is synthetic: @@ -157,7 +163,7 @@ export function TimelineGroupRow({ onToggleLanes={() => toggleLaneOwnerExpanded(group.id)} fxChain={group.fxChain} onFxChainChange={(next) => writeGroupFxChain(next, false)} - onFxChainPreview={(next) => writeGroupFxChain(next, true)} + onFxChainPreview={(next, ended) => writeGroupFxChain(next, true, ended)} auditionSpans={memberElements} onOpenFxRack={() => openGroupFxRack()} // Same width as every other row's header. The group row needs a real @@ -169,8 +175,8 @@ export function TimelineGroupRow({ theme={theme} /> {/* The group's OWN curves, under the strip. Selected-gated exactly like a - clip's: the binder writes through the dom-edit selection, so a lane is - editable once the group is selected — which clicking its name does. */} + clip's: a lane is editable once the group is selected, which clicking + its name does. */} {/* The label column for those lanes, on the accent rail — inside the sticky column above, so they pin with the header. */} {isLaneOpen && ( diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 8ff70cc4c7..eff4073c85 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -214,8 +214,12 @@ export function TimelineTrackHeader({ // track holding several ungrouped ones has no single chain — the design // doc refuses to build "N clips = N chains", so that case gets a pointer // at grouping (B6's normative rule) instead of a popover. - const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } = - useTimelineEditContextOptional(); + const { + onGroupClips, + onSetElementAttributeLive, + onSetElementAttributeQuiet, + onRevertElementAttributeLive, + } = useTimelineEditContextOptional(); const domEditActions = useDomEditActionsContextOptional(); const singleAudioClip = isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null; @@ -225,10 +229,19 @@ export function TimelineTrackHeader({ // needs to be TOLD that, so it earns the button and a refusal. const isVideoWithAudioTrack = !isAudioTrack && trackElements.some((el) => el.tag.toLowerCase() === "video"); - const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => { + const writeClipFxChain = ( + clip: TimelineElement, + next: HfAudioFxChain, + live: boolean, + ended = false, + ) => { const value = next.nodes.length ? serializeAudioFxChain(next) : null; - if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value); - else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset"); + if (!live) { + void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset"); + return; + } + onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value); + if (ended) onRevertElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR); }; const openClipFxRack = (clip: TimelineElement) => { void domEditActions?.handleTimelineElementSelect(clip); @@ -309,7 +322,9 @@ export function TimelineTrackHeader({ fxChainRaw={singleAudioClip.fxChain} trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)} onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)} - onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)} + onChainPreview={(next, ended) => + writeClipFxChain(singleAudioClip, next, true, ended) + } // Muted, an audition is silent — so the hover lifts the mute on // the running graph and puts it back on the way out, the same // borrow-and-return it already does with the playhead. diff --git a/packages/studio/src/player/components/automationGestureKeys.test.ts b/packages/studio/src/player/components/automationGestureKeys.test.ts deleted file mode 100644 index c88cfd2bd3..0000000000 --- a/packages/studio/src/player/components/automationGestureKeys.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createAutomationGestureKeys } from "./automationGestureKeys"; - -const keys = () => { - let n = 0; - return createAutomationGestureKeys(() => `g${++n}`); -}; - -describe("createAutomationGestureKeys", () => { - it("holds one key across every move of a drag", () => { - // Each move persists; history merges same-key entries inside 300ms. Separate - // keys, or a window that can expire mid-drag, is what made undo take back a - // fragment of the move instead of the move. - const g = keys(); - expect([g.live().key, g.live().key, g.live().key]).toEqual(["g1", "g1", "g1"]); - }); - - it("never lets the window expire", () => { - expect(keys().live().ms).toBe(Number.POSITIVE_INFINITY); - expect(keys().commit().ms).toBe(Number.POSITIVE_INFINITY); - }); - - it("ends the gesture on the release, under the same key", () => { - const g = keys(); - g.live(); - expect(g.commit().key).toBe("g1"); - }); - - it("starts a fresh key for the next drag", () => { - const g = keys(); - g.live(); - g.commit(); - expect(g.live().key).toBe("g2"); - }); - - it("gives a standalone commit its own key", () => { - // Deleting a range, pasting one, typing a value: one write, one step each. - const g = keys(); - expect(g.commit().key).toBe("g1"); - expect(g.commit().key).toBe("g2"); - }); - - it("does not join a commit to a gesture that already ended", () => { - const g = keys(); - g.live(); - g.commit(); - expect(g.commit().key).toBe("g2"); - }); -}); diff --git a/packages/studio/src/player/components/automationGestureKeys.ts b/packages/studio/src/player/components/automationGestureKeys.ts deleted file mode 100644 index 387ff52096..0000000000 --- a/packages/studio/src/player/components/automationGestureKeys.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Undo grouping for a lane gesture. - * - * Dragging a point persists on every move and once more on release. History - * coalesces entries that share a key and land within 300ms of each other, and - * these writes did neither: the moves and the release used different keys, and a - * drag slower than the window split into several entries. Undo then took back - * whatever fragment happened to be last, leaving the point near where the drag - * had dropped it — which reads as undo not working at all. - * - * So a gesture mints one key and holds it: every move and the release that ends - * it record under that key with no window to expire, and the next gesture gets a - * fresh one so two drags never collapse into a single step. - */ - -export interface CoalesceHint { - key: string; - /** No expiry: a gesture is one step however long the pointer is held. */ - ms: number; -} - -export interface AutomationGestureKeys { - /** A continuous write. Opens a gesture if one is not already open. */ - live(): CoalesceHint; - /** The persisting write that ends a gesture — or a standalone edit. */ - commit(): CoalesceHint; -} - -export function createAutomationGestureKeys( - mint: () => string = () => `automation-gesture:${++counter}`, -): AutomationGestureKeys { - let open: string | null = null; - const hint = (key: string): CoalesceHint => ({ key, ms: Number.POSITIVE_INFINITY }); - return { - live: () => { - open ??= mint(); - return hint(open); - }, - commit: () => { - // A commit with no drag before it — a Delete, a paste, a typed value — is - // its own step, so it mints rather than joining anything. - const key = open ?? mint(); - open = null; - return hint(key); - }, - }; -} - -let counter = 0; diff --git a/packages/studio/src/player/components/groupAutomationElement.ts b/packages/studio/src/player/components/groupAutomationElement.ts index c6954c9b83..739c3d5549 100644 --- a/packages/studio/src/player/components/groupAutomationElement.ts +++ b/packages/studio/src/player/components/groupAutomationElement.ts @@ -16,12 +16,18 @@ import type { TimelineElement } from "../store/playerStore"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; +const groupElements = new WeakSet(); + +export function isGroupAutomationElement(element: TimelineElement): boolean { + return groupElements.has(element); +} + /** The synthetic element's track number — the group row's own anchor. */ export function groupAutomationElement( group: Pick, compositionDuration: number, ): TimelineElement { - return { + const element: TimelineElement = { id: group.id, // The DOM id, so a write lands on the `` and not on a clip. domId: group.id, @@ -34,4 +40,6 @@ export function groupAutomationElement( ...(group.automation ? { automation: group.automation } : {}), ...(group.fxChain ? { fxChain: group.fxChain } : {}), }; + groupElements.add(element); + return element; } diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index f5995e69f4..4d4ea90a15 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -84,6 +84,7 @@ export interface TimelineEditCallbacks { ) => Promise | void; /** B7's bus strip: live-write the group's own attribute while dragging. */ onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void; + onRevertAudioGroupAttributeLive?: (groupId: string, attr: string) => void; /** ...and persist one undo entry on release. */ onSetAudioGroupAttributeQuiet?: ( groupId: string, @@ -106,6 +107,7 @@ export interface TimelineEditCallbacks { attr: string, value: string | null, ) => void; + onRevertElementAttributeLive?: (element: TimelineElement, attr: string) => void; onSetElementAttributeQuiet?: ( element: TimelineElement, attr: string, diff --git a/packages/studio/src/player/components/useAutomationEdgeStretch.ts b/packages/studio/src/player/components/useAutomationEdgeStretch.ts index ed7b2fd49f..3de3c9d595 100644 --- a/packages/studio/src/player/components/useAutomationEdgeStretch.ts +++ b/packages/studio/src/player/components/useAutomationEdgeStretch.ts @@ -51,7 +51,7 @@ export interface UseAutomationEdgeStretchInput { /** Pointer position as a clip-local time and a parameter value. */ pointAt(clientX: number, clientY: number): { t: number; v: number }; xOf(t: number): number; - commitPoints(points: HfAutomationLane["points"], persist: boolean): void; + commitPoints(points: HfAutomationLane["points"], persist: boolean, ended?: boolean): void; /** Clamp bound for the dragged edge. */ duration: number; readOnly?: boolean | undefined; @@ -252,9 +252,9 @@ export function useAutomationEdgeStretch({ onHint(null); if (!crossed.current) return; crossed.current = false; - // A live write is a preview, so putting the snapshot back through the same - // channel is the whole revert — there is nothing persisted to undo. - commitPoints(points, false); + // Nothing was persisted: the snapshot goes back as the last live write, which + // also ends the lane's gesture so its before-value does not outlive it. + commitPoints(points, false, true); onRangeSelect?.(origin.t0, origin.t1, origin.v0, origin.v1); }, [drag, onHint, commitPoints, onRangeSelect]); diff --git a/packages/studio/src/player/components/useAutomationLaneGestures.ts b/packages/studio/src/player/components/useAutomationLaneGestures.ts index 55fa5bf231..ec5a0314a5 100644 --- a/packages/studio/src/player/components/useAutomationLaneGestures.ts +++ b/packages/studio/src/player/components/useAutomationLaneGestures.ts @@ -46,7 +46,7 @@ export interface UseAutomationLaneGesturesInput { pointAt(clientX: number, clientY: number): { t: number; v: number }; xOf(t: number): number; yOf(v: number): number; - commitPoints(points: HfAutomationLane["points"], persist: boolean): void; + commitPoints(points: HfAutomationLane["points"], persist: boolean, ended?: boolean): void; /** Clip-local times a dragged point snaps to, on top of its own neighbours. */ snapTimes?: readonly number[] | undefined; readOnly?: boolean | undefined; diff --git a/packages/studio/src/player/components/useAutomationLanes.save.test.tsx b/packages/studio/src/player/components/useAutomationLanes.save.test.tsx new file mode 100644 index 0000000000..93df77d7c0 --- /dev/null +++ b/packages/studio/src/player/components/useAutomationLanes.save.test.tsx @@ -0,0 +1,1406 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DomEditProvider } from "../../contexts/DomEditContext"; +import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; +import { jsonResponse, requestUrl } from "../../hooks/fetchStubTestUtils"; +import { useTimelineEditing } from "../../hooks/useTimelineEditing"; +import { useEditHistoryActions, type EditHistoryHandle } from "../../hooks/useEditHistoryActions"; +import { usePreviewPersistence } from "../../hooks/usePreviewPersistence"; +import { groupInfoFor } from "../lib/timelineGroupInfo"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { serializeAutomation, type HfAutomation } from "@hyperframes/core/audio-automation"; +import { groupAutomationElement } from "./groupAutomationElement"; +import { useAutomationLanes, type AutomationLaneBinding } from "./useAutomationLanes"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type CanEdit = NonNullable[0]["canEdit"]>; +type DomEditValue = Parameters[0]["value"]; + +const SOURCE = + '' + + ''; +const LOCKED = { blocked: true as const, reason: "Reserved by an agent" }; +const NEXT = { version: 1 as const, lanes: [{ target: "volume", points: [{ t: 0, v: 0.5 }] }] }; + +const music: TimelineElement = { + id: "music", + key: "music", + domId: "music", + tag: "audio", + start: 0, + duration: 12, + track: 0, + sourceFile: "index.html", +}; + +const group = groupAutomationElement({ id: "hf-group", label: "Music", anchorKey: 0 }, 12); +const BOTH_LANES = [ + { lane: "clip", target: music, field: "automation" }, + { lane: "group", target: group, field: "audioGroupAutomation" }, +] as const; +const curve = (v: number): HfAutomation => ({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], +}); + +afterEach(() => { + document.body.innerHTML = ""; + usePlayerStore.getState().reset(); + vi.unstubAllGlobals(); +}); + +function mountLanes(target: TimelineElement, canEdit?: CanEdit, recording = false) { + let projectId = "p1"; + let composition = "index.html"; + // Every file but the open one, by `${project}/${path}`. + const files = new Map(); + const isRecordingRef = { current: recording }; + let file = SOURCE; + let reads = 0; + let undoTo = SOURCE; + let undoPath: string | null = null; + let undoHeld = Promise.resolve(); + const held = new Map>(); + const broken = new Set(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: Parameters[0]) => { + if (!requestUrl(input).includes("/files/")) return jsonResponse({}); + const n = ++reads; + await held.get(n); + if (broken.has(n)) throw new Error("offline"); + const [, owner, path] = /projects\/([^/]+)\/files\/([^?]+)/.exec(requestUrl(input)) ?? []; + const at = `${owner}/${decodeURIComponent(path ?? "")}`; + return jsonResponse({ content: at === `${projectId}/${composition}` ? file : files.get(at) }); + }), + ); + const put = (path: string, content: string) => { + if (path === composition) file = content; + else files.set(`${projectId}/${path}`, content); + }; + const holdRead = (n: number) => { + let release = () => {}; + held.set(n, new Promise((resolve) => (release = resolve))); + return release; + }; + const iframe = document.createElement("iframe"); + document.body.append(iframe); + iframe.contentDocument!.body.innerHTML = SOURCE; + usePlayerStore.getState().setElements([{ ...music, audioGroup: "hf-group" }]); + const writeProjectFile = vi.fn(async (path: string, content: string) => put(path, content)); + const refresh = vi.fn(async () => {}); + const selection = { id: target.id }; + const previewIframeRef = { current: iframe }; + let binding: AutomationLaneBinding | null = null; + let history: ReturnType | null = null; + const editHistory = { + // In the file queue, like the real step, so saves started meanwhile queue behind it. + undo: async ({ serialize }: Parameters[0]) => { + const path = undoPath ?? composition; + const step = async () => { + await undoHeld; + const previous = path === composition ? file : (files.get(`${projectId}/${path}`) ?? ""); + put(path, undoTo); + const restored = { [path]: { previous, restored: undoTo } }; + return { ok: true, label: "Undo", paths: [path], files: restored }; + }; + return serialize ? serialize([path], step) : step(); + }, + redo: async () => ({ ok: false, reason: "empty" }), + }; + + function Probe() { + binding = useAutomationLanes().bind(target, true); + return null; + } + function Host() { + const editing = useTimelineEditing({ + projectId, + activeCompPath: composition, + timelineElements: [music], + showToast: () => {}, + writeProjectFile, + recordEdit: async () => {}, + reloadPreview: () => {}, + previewIframeRef, + pendingTimelineEditPathRef: { current: new Set() }, + uploadProjectFiles: async () => [], + canEdit, + isRecordingRef, + }); + const persistence = usePreviewPersistence({ + showToast: () => {}, + readOptionalProjectFile: async () => "", + writeProjectFile, + recordEdit: async () => {}, + previewIframeRef, + activeCompPathRef: { current: composition }, + reloadPreview: () => {}, + }); + history = useEditHistoryActions({ + editHistory, + readOptionalProjectFile: async () => "", + readProjectFile: async () => file, + writeProjectFile, + showToast: () => {}, + syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply, + waitForPendingDomEditSaves: persistence.waitForPendingDomEditSaves, + // As App.tsx wires it. + onAfterUndoRedo: (restore) => editing.restoreLiveLanes(restore), + activeCompPath: composition, + }); + const domEdit = { + domEditSelectionRef: { current: selection }, + refreshDomEditSelectionFromPreview: refresh, + } as unknown as DomEditValue; + return ( + + + + + + ); + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => root.render()); + const commit = async (next: HfAutomation = NEXT) => { + let outcome: unknown; + await act(async () => { + outcome = await binding!.onCommit(next); + }); + return outcome; + }; + const preview = (next: HfAutomation = NEXT) => act(() => binding!.onPreview(next)); + const startCommit = (next: HfAutomation) => binding!.onCommit(next); + return { + commit, + startCommit, + preview, + writeProjectFile, + refresh, + selection, + iframe, + setFile: (next: string) => (file = next), + file: () => file, + reads: () => reads, + failRead: (n: number) => broken.add(n), + // A different project whose file, preview and store hold the same clip, unedited. + switchProject: (next: string, content = SOURCE) => { + files.set(`${projectId}/${composition}`, file); + projectId = next; + file = files.get(`${next}/${composition}`) ?? content; + iframe.contentDocument!.body.innerHTML = SOURCE; + usePlayerStore.getState().setElements([{ ...music, audioGroup: "hf-group" }]); + act(() => root.render()); + }, + // Another composition of the same project, loaded into the preview with `stored` in the store. + switchComposition: (next: string, content: string, stored: TimelineElement) => { + files.set(`${projectId}/${composition}`, file); + composition = next; + file = files.get(`${projectId}/${next}`) ?? content; + iframe.contentDocument!.body.innerHTML = file; + usePlayerStore.getState().setElements([stored]); + act(() => root.render()); + }, + fileAt: (path: string) => (path === composition ? file : files.get(`${projectId}/${path}`)), + put, + // Cmd+Z through the real history actions: the file goes back to `to`, the preview soft-restores. + undo: async (to: string, path?: string) => { + undoTo = to; + undoPath = path ?? null; + await act(async () => { + await history!.undo(); + }); + }, + // An undo whose round trip waits for the returned release. + startUndo: (to: string) => { + let release = () => {}; + undoTo = to; + undoHeld = new Promise((resolve) => (release = resolve)); + return { release, done: history!.undo() }; + }, + // A cancelled gesture's last live write, which puts its start back. + cancel: (next: HfAutomation) => act(() => binding!.onPreview(next, true)), + setRecording: (next: boolean) => (isRecordingRef.current = next), + holdRead, + }; +} + +describe("useAutomationLanes saves report what happened", () => { + it("saves a clip's automation, and the lane and the panel read it back", async () => { + const { commit, writeProjectFile, refresh, selection } = mountLanes(music); + expect(await commit()).toEqual({ status: "saved" }); + expect(writeProjectFile.mock.calls[0]?.[1]).toContain("data-automation="); + expect(usePlayerStore.getState().elements[0]?.automation).toContain('"volume"'); + expect(refresh).toHaveBeenCalledWith(selection); + }); + + it("refuses an automation edit on a locked clip and writes nothing", async () => { + const { commit, writeProjectFile } = mountLanes(music, () => LOCKED); + expect(await commit()).toEqual({ status: "refused", reason: "Reserved by an agent" }); + expect(writeProjectFile).not.toHaveBeenCalled(); + expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined(); + }); + + it("puts a dragged preview back when the lock refuses the release", async () => { + const { commit, iframe, preview } = mountLanes(music, () => LOCKED); + preview(); + const node = iframe.contentDocument!.getElementById("music"); + expect(node?.getAttribute("data-automation")).toContain('"volume"'); + await commit(); + expect(node?.hasAttribute("data-automation")).toBe(false); + }); + + it("puts a dragged preview back when a recording refuses the release", async () => { + const { commit, iframe, preview, writeProjectFile } = mountLanes(music, undefined, true); + preview(); + expect(await commit()).toEqual({ + status: "refused", + reason: "Cannot edit timeline while recording", + }); + expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe( + false, + ); + expect(writeProjectFile).not.toHaveBeenCalled(); + }); + + it("resolves an automation edit whose write fails as failed", async () => { + const { commit, writeProjectFile, iframe } = mountLanes(music); + writeProjectFile.mockRejectedValue(new Error("disk full")); + expect(await commit()).toEqual({ + status: "failed", + reason: expect.stringContaining("disk full"), + }); + expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe( + false, + ); + }); + + it("refuses a group lane edit when a member is locked", async () => { + const { commit, writeProjectFile } = mountLanes(group, (el) => + el.id === "music" ? LOCKED : true, + ); + expect(await commit()).toEqual({ status: "refused", reason: "Reserved by an agent" }); + expect(writeProjectFile).not.toHaveBeenCalled(); + }); + + it("saves a group lane on the group and mirrors it to the members", async () => { + const { commit, writeProjectFile } = mountLanes(group); + expect(await commit()).toEqual({ status: "saved" }); + expect(writeProjectFile.mock.calls[0]?.[1]).toMatch( + / { + const { startCommit, preview, writeProjectFile, iframe, setFile } = mountLanes(music); + const saved = serializeAutomation(NEXT); + let landFirst = () => {}; + writeProjectFile + .mockImplementationOnce( + (_path, content) => + new Promise((resolve) => { + landFirst = () => { + setFile(content); + resolve(); + }; + }), + ) + .mockRejectedValueOnce(new Error("disk full")); + preview(); + const first = startCommit(NEXT); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + const later = { + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v: 0.9 }] }], + }; + preview(later); + const second = startCommit(later); + landFirst(); + let outcomes: unknown[] = []; + await act(async () => { + outcomes = await Promise.all([first, second]); + }); + expect(outcomes).toMatchObject([{ status: "saved" }, { status: "failed" }]); + expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe( + saved, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBe(saved); + }); + + it("never lets an older failed save's recovery land over a newer save", async () => { + const { commit, startCommit, preview, writeProjectFile, iframe, file, reads, holdRead } = + mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + expect(await commit(at(0.5))).toEqual({ status: "saved" }); + const releaseRecovery = holdRead(3); + writeProjectFile.mockRejectedValueOnce(new Error("offline")); + preview(at(0.9)); + const older = startCommit(at(0.9)); + await act(() => vi.waitFor(() => expect(reads()).toBe(3))); + preview(at(0.7)); + const newer = startCommit(at(0.7)); + releaseRecovery(); + let outcomes: unknown[] = []; + await act(async () => { + outcomes = await Promise.all([older, newer]); + }); + expect(outcomes).toMatchObject([{ status: "failed" }, { status: "saved" }]); + const landed = serializeAutomation(at(0.7)); + const saved = new DOMParser().parseFromString(file(), "text/html"); + expect(saved.getElementById("music")?.getAttribute("data-automation")).toBe(landed); + expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe( + landed, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBe(landed); + }); + + it("keeps a newer save when an older failed save cannot read the file back", async () => { + const { startCommit, preview, writeProjectFile, iframe, file, failRead } = mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let failOlder = () => {}; + writeProjectFile.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failOlder = () => reject(new Error("offline")); + }), + ); + preview(at(0.9)); + const older = startCommit(at(0.9)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + preview(at(0.7)); + const newer = startCommit(at(0.7)); + failRead(3); + failOlder(); + await act(async () => { + await Promise.all([older, newer]); + }); + const landed = serializeAutomation(at(0.7)); + const saved = new DOMParser().parseFromString(file(), "text/html"); + expect(saved.getElementById("music")?.getAttribute("data-automation")).toBe(landed); + expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe( + landed, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBe(landed); + }); + + it.each([ + { lane: "clip", target: music, field: "automation", newer: "fails" }, + { lane: "clip", target: music, field: "automation", newer: "is refused" }, + { lane: "group", target: group, field: "audioGroupAutomation", newer: "fails" }, + { lane: "group", target: group, field: "audioGroupAutomation", newer: "is refused" }, + ] as const)( + "keeps a landed $lane save when a newer one $newer and cannot read the file back", + async ({ target, field, newer: how }) => { + const { + startCommit, + writeProjectFile, + iframe, + file, + setFile, + failRead, + reads, + setRecording, + } = mountLanes(target); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let landOlder = () => {}; + writeProjectFile + .mockImplementationOnce( + (_path, content) => + new Promise((resolve) => { + landOlder = () => { + setFile(content); + resolve(); + }; + }), + ) + .mockRejectedValueOnce(new Error("offline")); + const older = startCommit(at(0.5)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + if (how === "is refused") setRecording(true); + const newer = startCommit(at(0.9)); + failRead(how === "fails" ? 3 : reads() + 1); + landOlder(); + await act(async () => { + await Promise.all([older, newer]); + }); + const want = serializeAutomation(at(0.5)); + const saved = new DOMParser().parseFromString(file(), "text/html"); + expect(saved.getElementById(target.id)?.getAttribute("data-automation")).toBe(want); + expect( + iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation"), + ).toBe(want); + expect(usePlayerStore.getState().elements[0]?.[field]).toBe(want); + }, + ); + + it.each([{ olderReadsBack: true }, { olderReadsBack: false }])( + "never settles on a value that was not saved when a refused save cannot read the file back (older reads back: $olderReadsBack)", + async ({ olderReadsBack }) => { + const { startCommit, preview, writeProjectFile, iframe, failRead, setRecording } = + mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let failOlder = () => {}; + writeProjectFile.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failOlder = () => reject(new Error("offline")); + }), + ); + preview(at(0.9)); + const older = startCommit(at(0.9)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + preview(at(0.7)); + setRecording(true); + const refused = startCommit(at(0.7)); + failRead(2); + if (!olderReadsBack) failRead(3); + failOlder(); + await act(async () => { + await Promise.all([older, refused]); + }); + expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe( + false, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined(); + }, + ); + + it.each([ + { lane: "clip", target: music, field: "automation", trigger: "offline" }, + { lane: "clip", target: music, field: "automation", trigger: "element gone" }, + { lane: "group", target: group, field: "audioGroupAutomation", trigger: "offline" }, + { lane: "group", target: group, field: "audioGroupAutomation", trigger: "element gone" }, + ] as const)( + "puts a $lane drag back when its first save fails ($trigger) and cannot read the file back", + async ({ target, field, trigger }) => { + const { commit, preview, writeProjectFile, iframe, setFile, failRead } = mountLanes(target); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + if (trigger === "offline") { + writeProjectFile.mockRejectedValueOnce(new Error("offline")); + failRead(2); + } else { + setFile(""); + } + preview(at(0.9)); + expect(await commit(at(0.9))).toMatchObject({ status: "failed" }); + expect( + iframe.contentDocument!.getElementById(target.id)?.hasAttribute("data-automation"), + ).toBe(false); + expect(usePlayerStore.getState().elements[0]?.[field]).toBeUndefined(); + }, + ); + + it.each([ + { lane: "clip", target: music, field: "automation", dragReadFails: false }, + { lane: "clip", target: music, field: "automation", dragReadFails: true }, + { lane: "group", target: group, field: "audioGroupAutomation", dragReadFails: false }, + { lane: "group", target: group, field: "audioGroupAutomation", dragReadFails: true }, + ] as const)( + "never verifies a $lane drag's before-value that an unsaved paste left behind (drag read fails: $dragReadFails)", + async ({ target, field, dragReadFails }) => { + const { startCommit, commit, preview, writeProjectFile, iframe, failRead } = + mountLanes(target); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let failPaste = () => {}; + writeProjectFile + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failPaste = () => reject(new Error("offline")); + }), + ) + .mockRejectedValueOnce(new Error("offline")); + failRead(2); + failRead(4); + if (dragReadFails) failRead(3); + const paste = startCommit(at(0.5)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + preview(at(0.9)); + failPaste(); + await act(async () => { + await paste; + }); + expect(await commit(at(0.9))).toMatchObject({ status: "failed" }); + expect( + iframe.contentDocument!.getElementById(target.id)?.hasAttribute("data-automation"), + ).toBe(false); + expect(usePlayerStore.getState().elements[0]?.[field]).toBeUndefined(); + }, + ); + + it("reverts a refused drag to the last saved value when its before-value was never saved", async () => { + let locked = false; + const { startCommit, commit, preview, writeProjectFile, iframe, failRead } = mountLanes( + music, + () => (locked ? LOCKED : true), + ); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + expect(await commit(at(0.3))).toEqual({ status: "saved" }); + let failPaste = () => {}; + writeProjectFile.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failPaste = () => reject(new Error("offline")); + }), + ); + failRead(3); + const paste = startCommit(at(0.5)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(2))); + preview(at(0.9)); + failPaste(); + await act(async () => { + await paste; + }); + locked = true; + expect(await commit(at(0.9))).toMatchObject({ status: "refused" }); + const saved = serializeAutomation(at(0.3)); + expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe( + saved, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBe(saved); + }); + + it.each([ + { lane: "clip", target: music, field: "automation" }, + { lane: "group", target: group, field: "audioGroupAutomation" }, + ] as const)( + "never settles another project's saved $lane value when a save cannot read the file back", + async ({ target, field }) => { + const { commit, preview, writeProjectFile, iframe, failRead, switchProject } = + mountLanes(target); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + preview(at(0.5)); + expect(await commit(at(0.5))).toEqual({ status: "saved" }); + switchProject("p2"); + writeProjectFile.mockRejectedValueOnce(new Error("offline")); + failRead(3); + expect(await commit(at(0.9))).toMatchObject({ status: "failed" }); + expect( + iframe.contentDocument!.getElementById(target.id)?.hasAttribute("data-automation"), + ).toBe(false); + expect(usePlayerStore.getState().elements[0]?.[field]).toBeUndefined(); + }, + ); + + it.each([{ held: "write" }, { held: "read" }] as const)( + "leaves a new project alone when a save made before the switch lands after it (held: $held)", + async ({ held }) => { + const { + startCommit, + preview, + writeProjectFile, + iframe, + setFile, + holdRead, + reads, + switchProject, + } = mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let land = () => {}; + if (held === "write") { + writeProjectFile.mockImplementationOnce( + (_path, content) => + new Promise((resolve) => { + land = () => { + setFile(content); + resolve(); + }; + }), + ); + } else { + land = holdRead(1); + } + preview(at(0.5)); + const saving = startCommit(at(0.5)); + await act(() => + vi.waitFor(() => + held === "write" + ? expect(writeProjectFile).toHaveBeenCalledTimes(1) + : expect(reads()).toBe(1), + ), + ); + switchProject("p2"); + land(); + await act(async () => { + await saving; + }); + expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe( + false, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined(); + }, + ); + + it("reads a failed save back from its own project after a switch", async () => { + const { startCommit, writeProjectFile, iframe, failRead, reads, switchProject } = + mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let failFirst = () => {}; + writeProjectFile + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failFirst = () => reject(new Error("offline")); + }), + ) + .mockImplementationOnce(async () => { + failRead(reads() + 1); + throw new Error("offline"); + }); + const first = startCommit(at(0.5)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + const doc = new DOMParser().parseFromString(SOURCE, "text/html"); + doc.getElementById("music")!.setAttribute("data-automation", serializeAutomation(at(0.3))); + const other = doc.body.innerHTML; + switchProject("p2", other); + failFirst(); + await act(async () => { + await first; + }); + switchProject("p1"); + await act(async () => { + await startCommit(at(0.9)); + }); + expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe( + false, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined(); + }); + + it.each([ + { lane: "clip", target: music, field: "automation", later: "fails" }, + { lane: "clip", target: music, field: "automation", later: "is refused" }, + { lane: "group", target: group, field: "audioGroupAutomation", later: "fails" }, + { lane: "group", target: group, field: "audioGroupAutomation", later: "is refused" }, + ] as const)( + "never brings back a $lane value an undo removed when a later save $later and cannot read the file back", + async ({ target, field, later }) => { + const { + commit, + startCommit, + preview, + writeProjectFile, + iframe, + setFile, + failRead, + reads, + setRecording, + } = mountLanes(target); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + preview(at(0.5)); + expect(await commit(at(0.5))).toEqual({ status: "saved" }); + // Undo: file, preview and store go back to the clip without automation. + setFile(SOURCE); + iframe.contentDocument!.body.innerHTML = SOURCE; + usePlayerStore.getState().setElements([{ ...music, audioGroup: "hf-group" }]); + if (later === "fails") { + writeProjectFile.mockImplementationOnce(async () => { + failRead(reads() + 1); + throw new Error("offline"); + }); + } else { + setRecording(true); + failRead(reads() + 1); + } + await act(async () => { + await startCommit(at(0.9)); + }); + expect( + iframe.contentDocument!.getElementById(target.id)?.hasAttribute("data-automation"), + ).toBe(false); + expect(usePlayerStore.getState().elements[0]?.[field]).toBeUndefined(); + }, + ); + + it("reads a failed save back from its own project while another save is in flight across a switch", async () => { + const { startCommit, writeProjectFile, iframe, failRead, holdRead, reads, switchProject } = + mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let failFirst = () => {}; + writeProjectFile + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failFirst = () => reject(new Error("offline")); + }), + ) + .mockRejectedValueOnce(new Error("offline")); + const first = startCommit(at(0.5)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + const second = startCommit(at(0.7)); + const doc = new DOMParser().parseFromString(SOURCE, "text/html"); + doc.getElementById("music")!.setAttribute("data-automation", serializeAutomation(at(0.3))); + switchProject("p2", doc.body.innerHTML); + const releaseSecond = holdRead(4); + failRead(4); + failFirst(); + await act(() => vi.waitFor(() => expect(reads()).toBe(4))); + switchProject("p1"); + releaseSecond(); + await act(async () => { + await Promise.all([first, second]); + }); + expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe( + false, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined(); + }); + + it("leaves the store to a newer pending save when an older one lands", async () => { + const { startCommit, writeProjectFile, setFile, holdRead } = mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let landOlder = () => {}; + writeProjectFile.mockImplementationOnce( + (_path, content) => + new Promise((resolve) => { + landOlder = () => { + setFile(content); + resolve(); + }; + }), + ); + const older = startCommit(at(0.5)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + const releaseNewer = holdRead(2); + const newer = startCommit(at(0.9)); + landOlder(); + await act(async () => { + await older; + }); + expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined(); + releaseNewer(); + await act(async () => { + await newer; + }); + expect(usePlayerStore.getState().elements[0]?.automation).toBe(serializeAutomation(at(0.9))); + }); + + it("keeps a newer release's preview while an older save fails under it", async () => { + const { startCommit, preview, writeProjectFile, iframe, holdRead } = mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + const shown = () => + iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation"); + let failOlder = () => {}; + writeProjectFile.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failOlder = () => reject(new Error("offline")); + }), + ); + preview(at(0.9)); + const older = startCommit(at(0.9)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + const releaseNewer = holdRead(2); + preview(at(0.7)); + const newer = startCommit(at(0.7)); + failOlder(); + await act(() => new Promise((resolve) => setTimeout(resolve, 20))); + expect(shown()).toBe(serializeAutomation(at(0.7))); + releaseNewer(); + await act(async () => { + await Promise.all([older, newer]); + }); + expect(shown()).toBe(serializeAutomation(at(0.7))); + }); + + it("keeps a newer live drag's preview when an older save's recovery lands", async () => { + const { startCommit, preview, writeProjectFile, iframe, reads, holdRead } = mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + const releaseRecovery = holdRead(2); + writeProjectFile.mockRejectedValueOnce(new Error("offline")); + preview(at(0.9)); + const older = startCommit(at(0.9)); + await act(() => vi.waitFor(() => expect(reads()).toBe(2))); + preview(at(0.7)); + releaseRecovery(); + await act(async () => { + await older; + }); + expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe( + serializeAutomation(at(0.7)), + ); + }); + + it("reverts a refused drag to the file after an older save failed under it", async () => { + let locked = false; + const { commit, startCommit, preview, writeProjectFile, iframe } = mountLanes(music, () => + locked ? LOCKED : true, + ); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let failFirst = () => {}; + writeProjectFile.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failFirst = () => reject(new Error("offline")); + }), + ); + preview(at(0.9)); + const older = startCommit(at(0.9)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + preview(at(0.7)); + failFirst(); + await act(async () => { + await older; + }); + locked = true; + expect(await commit(at(0.7))).toMatchObject({ status: "refused" }); + expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe( + false, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined(); + }); + + it("stores a save that lands after a newer live write was cancelled", async () => { + const { startCommit, preview, writeProjectFile, setFile } = mountLanes(music); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let land = () => {}; + writeProjectFile.mockImplementationOnce( + (_path, content) => + new Promise((resolve) => { + land = () => { + setFile(content); + resolve(); + }; + }), + ); + preview(at(0.5)); + const saving = startCommit(at(0.5)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + preview(at(0.9)); + preview(at(0.5)); + land(); + await act(async () => { + await saving; + }); + expect(usePlayerStore.getState().elements[0]?.automation).toBe(serializeAutomation(at(0.5))); + }); + + it.each([ + { lane: "clip", target: music, field: "automation", pendingLands: true, expected: 0.7 }, + { lane: "clip", target: music, field: "automation", pendingLands: false, expected: null }, + { + lane: "group", + target: group, + field: "audioGroupAutomation", + pendingLands: true, + expected: 0.7, + }, + { + lane: "group", + target: group, + field: "audioGroupAutomation", + pendingLands: false, + expected: null, + }, + ] as const)( + "agrees with the file when a $lane drag is refused between an older failure and a pending save (lands: $pendingLands)", + async ({ target, field, pendingLands, expected }) => { + let locked = false; + const { commit, startCommit, preview, writeProjectFile, iframe, file, setFile } = mountLanes( + target, + () => (locked ? LOCKED : true), + ); + const stored = () => usePlayerStore.getState().elements[0]?.[field] ?? null; + const shown = () => + iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation") ?? null; + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + let failOlder = () => {}; + let finishPending = () => {}; + writeProjectFile + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failOlder = () => reject(new Error("offline")); + }), + ) + .mockImplementationOnce( + (_path, content) => + new Promise((resolve, reject) => { + finishPending = () => { + if (!pendingLands) return reject(new Error("offline")); + setFile(content); + resolve(); + }; + }), + ); + preview(at(0.9)); + const older = startCommit(at(0.9)); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + preview(at(0.7)); + const pending = startCommit(at(0.7)); + preview(at(0.5)); + failOlder(); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(2))); + expect(shown()).toBe(serializeAutomation(at(0.5))); + locked = true; + expect(await commit(at(0.5))).toMatchObject({ status: "refused" }); + finishPending(); + await act(async () => { + await Promise.all([older, pending]); + }); + const want = expected === null ? null : serializeAutomation(at(expected)); + const saved = new DOMParser().parseFromString(file(), "text/html"); + expect(saved.getElementById(target.id)?.getAttribute("data-automation") ?? null).toBe(want); + expect(shown()).toBe(want); + expect(stored()).toBe(want); + }, + ); + + it.each([ + { lane: "clip", target: music, field: "automation", newer: "saves", expected: 0.7 }, + { lane: "clip", target: music, field: "automation", newer: "fails", expected: 0.9 }, + { lane: "clip", target: music, field: "automation", newer: "is refused", expected: 0.9 }, + { lane: "group", target: group, field: "audioGroupAutomation", newer: "saves", expected: 0.7 }, + { lane: "group", target: group, field: "audioGroupAutomation", newer: "fails", expected: 0.9 }, + { + lane: "group", + target: group, + field: "audioGroupAutomation", + newer: "is refused", + expected: 0.9, + }, + ] as const)( + "lands saves in the order they start when an older $lane save reads slowly and the newer one $newer", + async ({ target, field, newer, expected }) => { + const { + startCommit, + preview, + writeProjectFile, + iframe, + file, + holdRead, + setFile, + setRecording, + } = mountLanes(target); + const at = (v: number) => ({ + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v }] }], + }); + writeProjectFile.mockImplementation(async (_path, content) => { + if (newer === "fails" && content.includes("0.7")) throw new Error("offline"); + setFile(content); + }); + const releaseOlder = holdRead(1); + preview(at(0.9)); + const older = startCommit(at(0.9)); + if (newer === "is refused") setRecording(true); + preview(at(0.7)); + const pending = startCommit(at(0.7)); + await act(() => new Promise((resolve) => setTimeout(resolve, 50))); + releaseOlder(); + await act(async () => { + await Promise.all([older, pending]); + }); + const want = serializeAutomation(at(expected)); + const saved = new DOMParser().parseFromString(file(), "text/html"); + expect(saved.getElementById(target.id)?.getAttribute("data-automation")).toBe(want); + expect( + iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation"), + ).toBe(want); + expect(usePlayerStore.getState().elements[0]?.[field]).toBe(want); + }, + ); + + it("settles on a queued save that lands after an earlier one fails", async () => { + const { startCommit, preview, writeProjectFile, iframe, setFile } = mountLanes(music); + let failFirst = () => {}; + writeProjectFile + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failFirst = () => reject(new Error("offline")); + }), + ) + .mockImplementationOnce(async (_path, content) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + setFile(content); + }); + preview(); + const first = startCommit(NEXT); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + const later = { + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v: 0.9 }] }], + }; + preview(later); + const second = startCommit(later); + await act(() => new Promise((resolve) => setTimeout(resolve, 20))); + failFirst(); + let outcomes: unknown[] = []; + await act(async () => { + outcomes = await Promise.all([first, second]); + }); + expect(outcomes).toMatchObject([{ status: "failed" }, { status: "saved" }]); + const landed = serializeAutomation(later); + expect(iframe.contentDocument!.getElementById("music")?.getAttribute("data-automation")).toBe( + landed, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBe(landed); + }); + + it("settles on the file when two overlapping saves both fail", async () => { + const { startCommit, preview, writeProjectFile, iframe } = mountLanes(music); + let failFirst = () => {}; + writeProjectFile + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failFirst = () => reject(new Error("offline")); + }), + ) + .mockRejectedValueOnce(new Error("offline")); + preview(); + const first = startCommit(NEXT); + await act(() => vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledTimes(1))); + const later = { + version: 1 as const, + lanes: [{ target: "volume", points: [{ t: 0, v: 0.9 }] }], + }; + preview(later); + const second = startCommit(later); + failFirst(); + let outcomes: unknown[] = []; + await act(async () => { + outcomes = await Promise.all([first, second]); + }); + expect(outcomes).toMatchObject([{ status: "failed" }, { status: "failed" }]); + expect(iframe.contentDocument!.getElementById("music")?.hasAttribute("data-automation")).toBe( + false, + ); + expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined(); + }); + + it("puts a group's dragged preview and mirror back when a recording refuses the release", async () => { + const { commit, preview, iframe } = mountLanes(group, undefined, true); + preview(); + expect(usePlayerStore.getState().elements[0]?.audioGroupAutomation).toContain('"volume"'); + expect(await commit()).toMatchObject({ status: "refused" }); + expect( + iframe.contentDocument!.getElementById("hf-group")?.hasAttribute("data-automation"), + ).toBe(false); + expect(usePlayerStore.getState().elements[0]?.audioGroupAutomation).toBeUndefined(); + }); + + it.each( + BOTH_LANES.flatMap((lane) => + (["mid-drag", "with a live write left open"] as const).flatMap((undo) => + (["is refused", "goes offline"] as const).map((release) => ({ ...lane, undo, release })), + ), + ), + )( + "keeps the undone $lane value when an undo lands $undo and the next save $release with no read-back", + async ({ target, field, undo: when, release }) => { + const h = mountLanes(target); + expect(await h.commit(curve(0.2))).toEqual({ status: "saved" }); + const base = h.file(); + expect(await h.commit(curve(0.5))).toEqual({ status: "saved" }); + h.preview(curve(0.9)); + if (when === "mid-drag") { + await h.undo(base); + h.preview(curve(0.9)); + } else { + h.preview(curve(0.5)); + await h.undo(base); + } + if (release === "is refused") h.setRecording(true); + else h.failRead(h.reads() + 2); + h.failRead(h.reads() + 1); + await act(async () => { + await h.startCommit(curve(0.9)); + }); + const want = serializeAutomation(curve(0.2)); + const saved = new DOMParser().parseFromString(h.file(), "text/html"); + expect(saved.getElementById(target.id)?.getAttribute("data-automation")).toBe(want); + expect( + h.iframe.contentDocument!.getElementById(target.id)?.getAttribute("data-automation"), + ).toBe(want); + expect(usePlayerStore.getState().elements[0]?.[field]).toBe(want); + }, + ); + + it("clears a group's mirrored automation when an undo soft-restores the group without it", async () => { + const { commit, iframe, undo, setFile } = mountLanes(group); + const grouped = SOURCE.replace( + '