From 4f18cdfe9ee843c871aa54a88473033de6f9c144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:11:40 +0800 Subject: [PATCH 01/14] fix(editor): fold a probed duration in on the shared write queue `useSequentialTimelineOps` exists because every timeline edit is a read-modify-write of the whole document, and its header says so: anything that reads the doc and saves it back belongs on that chain. The `loadedmetadata` handler did neither -- it read `getState().document` and issued its own save. That is enough to lose an edit, because the store is only written once the bridge answers. A user's save in flight leaves `getState()` returning the PRE-edit document, the probe builds a full snapshot from it, and whichever write lands second wins. The epoch check in `saveDocument` does not cover this: it guards undo, redo and project switches, not a concurrent save. Move the read, the compute and the write inside `enqueueTimelineWrite`, which the shell already holds, and await the saves so the queue actually waits for them -- a fire-and-forget write would let the next queued edit read a document this one has not committed yet. --- src/components/ai-edition/NewEditorShell.tsx | 91 +++++++++++--------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index b82c2a1cf..e04887d95 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -429,47 +429,60 @@ export function NewEditorShell() { // placeholder. All store reads go through getState() to avoid // stale-closure bugs. const known = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 60; - const state = useProjectStore.getState(); setSourceDuration(known); - const doc = state.document; - if (!doc || doc.assets.length === 0) return; - if (doc.timeline.clips.length === 0) { - // ponytail: replaceTimeline derives clip length from - // asset.durationSec, which import never populates — without this - // patch the first auto-created clip silently comes out empty - // (normalizeIntervals clamps against a 0 duration and drops it). - const primaryAssetId = doc.project.primaryAssetId ?? doc.assets[0]?.id; - const docWithDuration = primaryAssetId - ? { - ...doc, - assets: doc.assets.map((a) => - a.id === primaryAssetId ? { ...a, durationSec: known } : a, - ), - } - : doc; - const next = replaceTimelineOp( - docWithDuration, - [{ startSec: 0, endSec: known }], - "Auto-created full-duration clip", - ); - // `history: false` for both writes in this callback: they are the probed - // duration being folded into the document on load, not something the user - // did — an undo landing on one of them would empty their timeline. - void state.saveDocument(next, { history: false }); - return; - } - // Hand the probed duration to the pure document layer: it patches only the - // clips of THIS asset that are still waiting for a real length (the - // pre-probe placeholder, or the extent-less clip a legacy v2 import mints), - // shifts what follows, and brings the modifiers along — anchoring the ones - // migration had to leave unanchored. Returns the document untouched when - // nothing is waiting, so there is nothing to guard here. - const next = applyProbedDuration(doc, assetId, known); - if (next !== doc) { - void state.saveDocument(next, { history: false }); - } + // On the shared write queue, and reading the document inside it. Folding a + // probed duration in is a read-modify-write of the whole document, which is + // what `useSequentialTimelineOps` exists for -- its header says anything that + // reads the doc and saves it back belongs there. Off the queue, `getState()` + // returns the PRE-edit document while a user's save is still in flight (the + // store is only written once the bridge answers), and the full snapshot built + // from it lands after theirs and takes their edit with it. + void enqueueTimelineWrite(async () => { + const state = useProjectStore.getState(); + const doc = state.document; + if (!doc || doc.assets.length === 0) return; + if (doc.timeline.clips.length === 0) { + // ponytail: replaceTimeline derives clip length from + // asset.durationSec, which import never populates — without this + // patch the first auto-created clip silently comes out empty + // (normalizeIntervals clamps against a 0 duration and drops it). + const primaryAssetId = doc.project.primaryAssetId ?? doc.assets[0]?.id; + const docWithDuration = primaryAssetId + ? { + ...doc, + assets: doc.assets.map((a) => + a.id === primaryAssetId ? { ...a, durationSec: known } : a, + ), + } + : doc; + const next = replaceTimelineOp( + docWithDuration, + [{ startSec: 0, endSec: known }], + "Auto-created full-duration clip", + ); + // `history: false` for both writes in this callback: they are the probed + // duration being folded into the document on load, not something the user + // did — an undo landing on one of them would empty their timeline. + // + // Awaited, not `void`ed: the queue only serialises what it can see finish, + // so a fire-and-forget write here would let the next queued edit read the + // document this one has not committed yet. + await state.saveDocument(next, { history: false }); + return; + } + // Hand the probed duration to the pure document layer: it patches only the + // clips of THIS asset that are still waiting for a real length (the + // pre-probe placeholder, or the extent-less clip a legacy v2 import mints), + // shifts what follows, and brings the modifiers along — anchoring the ones + // migration had to leave unanchored. Returns the document untouched when + // nothing is waiting, so there is nothing to guard here. + const next = applyProbedDuration(doc, assetId, known); + if (next !== doc) { + await state.saveDocument(next, { history: false }); + } + }); }, - [setSourceDuration], + [setSourceDuration, enqueueTimelineWrite], ); const handleSeek = useCallback( From 2130023b666b195df4fe3c6ee423884deb761b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:11:52 +0800 Subject: [PATCH 02/14] fix(timeline): read the document at write time in addZoomsBulk Its `add*` siblings compute and save in the same tick, so reading the render closure is harmless there. This one is different: the wand captures the callback, awaits a multi-second cursor-telemetry IPC, and only then calls it. Anything the user commits during that wait is in the store but not in the closure, so the snapshot written back is missing their edit -- and it was also anchoring the new regions against clips that may no longer exist. Read from the store inside the callback, and anchor against that same document, matching `applyClipEdit`, `setTrimEntries` and `insertClipAt` -- which is also what lets this compose with `useSequentialTimelineOps` instead of racing it. The test captures the callback before the store moves, the way the wand does, and fails against the closure read: the saved document comes back carrying the old title, with the user's edit gone. --- src/lib/ai-edition/store/useTimeline.test.ts | 60 ++++++++++++++++++++ src/lib/ai-edition/store/useTimeline.ts | 21 +++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 2bc430311..e9ad26df1 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -1596,3 +1596,63 @@ describe("useTimeline audio tracks", () => { expect(probeAudioDurationMock).toHaveBeenCalledTimes(1); }); }); + +// The wand (`V4Timeline.runAutoZooms`) captures this callback, awaits a +// multi-second cursor-telemetry IPC, and only then calls it. Anything the user +// commits during that wait is in the store but not in the callback's render +// closure, so reading the closure writes back a snapshot that drops their edit. +// Its `add*` siblings compute and save in the same tick, which is why this one +// is the reachable case. +describe("useTimeline.addZoomsBulk reads the document at write time", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + beforeEach(() => { + useProjectStore.getState().clear(); + for (const mock of Object.values(bridgeMocks)) mock.mockReset(); + toastErrorMock.mockReset(); + bridgeMocks.save.mockImplementation(async (document: AxcutDocument) => ({ + success: true, + document, + })); + useProjectStore.setState({ + projectId: "proj_test", + document: sampleDoc, + revision: 1, + status: "ready", + error: null, + }); + }); + + it("does not write back the document from before the telemetry wait", async () => { + const { result } = renderTimeline(); + // Captured the way the wand captures it: before the wait, not after. + const addZoomsBulk = result.current.addZoomsBulk; + + // The user's edit lands while the wand is off fetching telemetry. + const edited: AxcutDocument = { + ...sampleDoc, + project: { ...sampleDoc.project, title: "Edited while the wand was busy" }, + }; + act(() => { + useProjectStore.setState({ document: edited, revision: 2 }); + }); + + let added: number | undefined; + await act(async () => { + added = await addZoomsBulk([ + { span: { start: 1000, end: 2000 }, focus: { cx: 0.5, cy: 0.5 } }, + ]); + }); + + expect(added).toBe(1); + const saved = bridgeMocks.save.mock.calls.at(-1)?.[0] as AxcutDocument; + // The zoom was added, and the edit is still there. + expect(saved.zoomRanges).toHaveLength(1); + expect(saved.project.title).toBe("Edited while the wand was busy"); + expect(useProjectStore.getState().document?.project.title).toBe( + "Edited while the wand was busy", + ); + }); +}); diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 40e2408fd..860b9f13f 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -334,7 +334,15 @@ export function useTimeline() { // Returns the count actually added (0 when there's no doc/suggestions). const addZoomsBulk = useCallback( async (suggestions: AutoZoomSuggestion[]) => { - if (!document || suggestions.length === 0) return 0; + // Read from the store, not off the render closure. Unlike its `add*` siblings, + // which compute and save in the same tick, this one is reached from the wand + // AFTER a multi-second cursor-telemetry IPC: the closure document is the one + // from before that wait, so anything the user committed during it is missing + // from the snapshot, and writing the snapshot back drops their edit. Reading + // here is also what lets this compose with `useSequentialTimelineOps` -- same + // reason as `applyClipEdit`, `setTrimEntries` and `insertClipAt`. + const doc = useProjectStore.getState().document; + if (!doc || suggestions.length === 0) return 0; const anchored = suggestions.flatMap((s) => anchorRegionsWithDerivedMs( [ @@ -347,18 +355,21 @@ export function useTimeline() { focusMode: "auto" as const, }, ], - document.timeline.clips, + // Anchored against the SAME document the write is built from: anchoring + // on the stale clips and saving the fresh document would place regions + // against a timeline that no longer exists. + doc.timeline.clips, () => createId("zoom"), ), ); const next: AxcutDocument = { - ...document, - zoomRanges: [...document.zoomRanges, ...anchored] as AxcutDocument["zoomRanges"], + ...doc, + zoomRanges: [...doc.zoomRanges, ...anchored] as AxcutDocument["zoomRanges"], }; if (!(await saveDocument(next, { history: true }))) return 0; return suggestions.length; }, - [document, saveDocument], + [saveDocument], ); const addTrim = useCallback( From 955a9eda6c8f68484b2e53a7820c835f4e8a9b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:24:39 +0800 Subject: [PATCH 03/14] fix(editor): bind the probed duration to the take that produced it Putting the write on the queue fixed one race and opened a narrower one: there is now real time between the metadata event and the write, and the duration in hand came off the video that fired the event. Two ways it can land somewhere it does not belong. Across projects: switch while the task is queued and the document read inside it is the new project, which gets the old video's length. `saveDocument`'s epoch check cannot see this -- the write is issued after the switch, not across it. So the event is bound to the project that was open when it fired, and dropped if that is no longer the one loaded. Within one project: the seed branch stamps the duration on the primary asset and sizes the clip from it, and `replaceTimeline` hard-codes clips to that same primary asset -- so an event from any OTHER asset seeds one video's length under another's id. Pre-existing, but the queue delay is what makes a primary change between the event and the write reachable at all. Seed only when the asset that fired is the one the seed is about; the primary's own event does its own seeding. The sibling branch needed neither guard: `applyProbedDuration` is handed the asset id and returns the document untouched when it does not hold it. --- src/components/ai-edition/NewEditorShell.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index e04887d95..41c2be0b1 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -437,16 +437,30 @@ export function NewEditorShell() { // returns the PRE-edit document while a user's save is still in flight (the // store is only written once the bridge answers), and the full snapshot built // from it lands after theirs and takes their edit with it. + // Bound to the project that was open when the event fired. Queueing puts real + // time between the two, and `known` came off THAT video: applied to a project + // the user switched to meanwhile it is simply a wrong number, and + // `saveDocument`'s epoch check cannot catch it because the write is issued + // after the switch, not across it. + const originatingProjectId = useProjectStore.getState().document?.project.id; void enqueueTimelineWrite(async () => { const state = useProjectStore.getState(); const doc = state.document; if (!doc || doc.assets.length === 0) return; + if (doc.project.id !== originatingProjectId) return; if (doc.timeline.clips.length === 0) { // ponytail: replaceTimeline derives clip length from // asset.durationSec, which import never populates — without this // patch the first auto-created clip silently comes out empty // (normalizeIntervals clamps against a 0 duration and drops it). const primaryAssetId = doc.project.primaryAssetId ?? doc.assets[0]?.id; + // Only the asset that actually fired this event. The seed stamps `known` + // on the primary asset and sizes the whole clip from it, so on a project + // whose primary is some OTHER asset that is one video's length written as + // another's -- a full-duration clip at the wrong length. The sibling + // branch never had this hole: `applyProbedDuration` is handed `assetId` + // and returns the document untouched when it does not hold it. + if (primaryAssetId !== assetId) return; const docWithDuration = primaryAssetId ? { ...doc, From de5b93aed2efa5c7a630bd73da5490efbc5ca1ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:35:52 +0800 Subject: [PATCH 04/14] test(editor): cover the probed-duration decision, guards included The two guards in the previous commit shipped untested, on the argument that the `loadedmetadata` path has no component harness. That confused "cannot test the component" with "cannot test the behaviour": the decision is pure -- a document, an asset id, a duration and the project the event came from -- and only the queueing around it needs the component. So the decision moves to an exported `documentAfterProbedDuration` and the handler keeps the wiring. Both guards now fail their tests when removed: without the project binding a switched-to project takes the old video's length, and without the asset check a non-primary asset seeds a clip that `replaceTimeline` pins to the primary. The seed, the fold-in and the already-settled cases are covered too. Two `saveDocument` call sites became one, so the write-audit table loses a row. --- .../NewEditorShell.probedDuration.test.tsx | 131 ++++++++++++++++++ src/components/ai-edition/NewEditorShell.tsx | 129 +++++++++-------- .../store/documentWriteAudit.test.ts | 1 - 3 files changed, 202 insertions(+), 59 deletions(-) create mode 100644 src/components/ai-edition/NewEditorShell.probedDuration.test.tsx diff --git a/src/components/ai-edition/NewEditorShell.probedDuration.test.tsx b/src/components/ai-edition/NewEditorShell.probedDuration.test.tsx new file mode 100644 index 000000000..ca1bf87a4 --- /dev/null +++ b/src/components/ai-edition/NewEditorShell.probedDuration.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +// The decision a `loadedmetadata` event makes, on its own. +// +// The event itself arrives through Preview -> PreviewCanvas -> VirtualPreview and a +// real