diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index b82c2a1cf..966926aa0 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -14,10 +14,7 @@ import { migrateProjectDataToAxcutDocument, migrateRawDocumentToCurrent, } from "@/lib/ai-edition/document/migrate"; -import { - applyProbedDuration, - replaceTimeline as replaceTimelineOp, -} from "@/lib/ai-edition/document/timeline"; +import { documentAfterProbedDuration } from "@/lib/ai-edition/document/timeline"; import { type InsertSide, insertDocumentWord, @@ -426,50 +423,39 @@ export function NewEditorShell() { // ponytail: WebM recordings from MediaRecorder report NaN/Infinity // until the main-process EBML fix lands. Fall back to a 60s seed if // duration is unknown so the timeline never gets stuck on an empty - // placeholder. All store reads go through getState() to avoid - // stale-closure bugs. + // placeholder. 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", + // Read before queueing: this is the project the event belongs to. What the + // decision does with it is `documentAfterProbedDuration`'s business. + const originatingProjectId = useProjectStore.getState().document?.project.id; + // 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 next = documentAfterProbedDuration( + state.document, + assetId, + known, + originatingProjectId, ); - // `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 }); - } + if (!next) return; + // `history: false`: this is the probed duration being folded into the + // document on load, not something the user did — an undo landing on it would + // empty their timeline. + // + // Awaited, not `void`ed: the queue only serialises what it can see finish, so + // a fire-and-forget write would let the next queued edit read a document this + // one has not committed yet. + await state.saveDocument(next, { history: false }); + }); }, - [setSourceDuration], + [setSourceDuration, enqueueTimelineWrite], ); const handleSeek = useCallback( @@ -1559,6 +1545,10 @@ export function NewEditorShell() { hasProject={hasProject} hasAsset={hasAsset} videoSources={videoSources} + // While the timeline is empty the preview mounts this asset rather + // than whichever one sorts first, so the clip `handleLoadedMetadata` + // seeds comes from the video it is sized against. + primaryAssetId={document?.project.primaryAssetId} // Imported audio tracks (issue #350). `videoSources` already // resolves a URL for every asset (audio included), so it doubles as // the audio source list; VirtualPreview looks each track up by assetId. diff --git a/src/components/ai-edition/Preview.test.tsx b/src/components/ai-edition/Preview.test.tsx index db84828b8..d4810ce70 100644 --- a/src/components/ai-edition/Preview.test.tsx +++ b/src/components/ai-edition/Preview.test.tsx @@ -75,6 +75,7 @@ function source(id: string): VideoSource { function previewProps(props: { videoSources: VideoSource[]; clips: AxcutClip[]; + primaryAssetId?: string; hasAsset?: boolean; hasProject?: boolean; }) { @@ -84,6 +85,7 @@ function previewProps(props: { hasProject={props.hasProject ?? true} hasAsset={props.hasAsset ?? true} videoSources={props.videoSources} + primaryAssetId={props.primaryAssetId} clips={props.clips} seekTarget={null} onTimeChange={vi.fn()} @@ -157,12 +159,48 @@ describe("Preview follows the timeline, not the asset list", () => { // The bootstrap path: `handleLoadedMetadata` mints the very first clip from // the