From 9326d9f1bafa615b200435a74792d2d485e1c68b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 19 Oct 2026 22:18:08 +0200 Subject: [PATCH] fix(audio): play a track once across its pill, so speed regions keep it on the clock The document stores a track as one fragment per clip it covers, each fragment's offset advanced by the ruler time before it. The export mixed, and the preview played, every fragment on its own: at its projected head, from its ruler-advanced offset. Under a slowed clip a bed played its ruler seconds of file over twice as many output seconds, went silent for the rest, then came back late; through a sped-up clip the slices overlapped. Measured on a 50 s film: 0.6 s of silence at the end of a 0.5x shot, and the music 2.7 s behind by the end. Both now take each track once, from its head fragment, as the whole pill: the voiceover path already did this to stop a take playing on top of itself. --- src/components/ai-edition/VirtualPreview.tsx | 43 +++++++++--------- src/lib/ai-edition/document/audioTracks.ts | 16 +++++++ src/native/sceneDescription.test.ts | 46 ++++++++++++++++++++ src/native/sceneDescription.ts | 31 +++++-------- 4 files changed, 96 insertions(+), 40 deletions(-) diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx index 4e4ec9e550..19c0fb1175 100644 --- a/src/components/ai-edition/VirtualPreview.tsx +++ b/src/components/ai-edition/VirtualPreview.tsx @@ -11,8 +11,8 @@ import { takeCrossedClickHits, } from "@/lib/ai-edition/clickSound"; import { - collapseTracksToPills, resolveFadeSecs, + takesByHeadFragment, trackGroupId, } from "@/lib/ai-edition/document/audioTracks"; import { @@ -875,23 +875,22 @@ export function VirtualPreview({ // One walk per take, recomputed only when the cuts or the insertions move. The rAF asks // it every frame per track, and walking on each would be wasteful. const takePiecesRef = useRef>(new Map()); - const takeHeadsRef = useRef>(new Map()); + // Each take keyed by the fragment whose element plays it, as the export mixes it. + const takesRef = useRef>(new Map()); const removedRef = useRef(removedRawSpans(clips, trimRanges)); removedRef.current = useMemo(() => removedRawSpans(clips, trimRanges), [clips, trimRanges]); const takeWalks = useMemo(() => { const pieces = new Map(); - const heads = new Map(); const removed = removedRawSpans(clips, trimRanges); - for (const pill of collapseTracksToPills(audioTracks)) { + const takes = takesByHeadFragment(audioTracks); + for (const pill of takes.values()) { if (pill.kind !== "voiceover" || pill.loop) continue; - const groupId = trackGroupId(pill); - heads.set(groupId, pill.id); - pieces.set(groupId, takeProgramme(pill, removed)); + pieces.set(trackGroupId(pill), takeProgramme(pill, removed)); } - return { pieces, heads }; + return { pieces, takes }; }, [audioTracks, clips, trimRanges]); takePiecesRef.current = takeWalks.pieces; - takeHeadsRef.current = takeWalks.heads; + takesRef.current = takeWalks.takes; // Trim-narrowed (`resolvePlaybackSegments`) — used ONLY to detect "has the