Skip to content

Commit 2159f42

Browse files
mikemikimikeEtienneLescot
authored andcommitted
fix(editor): record voiceovers against the uncut timeline (#1195)
1 parent 6e09c9d commit 2159f42

11 files changed

Lines changed: 509 additions & 48 deletions

‎src/components/ai-edition/NativeCompositorOverlay.test.tsx‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ import { publishNativePosition } from "@/native/nativeSync";
1818
const native = vi.hoisted(() => ({
1919
setActiveClip: vi.fn(async () => ({ ok: true })),
2020
setNativePlaying: vi.fn(),
21+
setNativeScene: vi.fn(),
2122
}));
2223

2324
vi.mock("@/native", () => ({
2425
pushAllNativeParams: vi.fn(),
2526
setActiveClip: native.setActiveClip,
2627
setCurrentNativeViewId: vi.fn(),
2728
setNativePlaying: native.setNativePlaying,
28-
setNativeScene: vi.fn(),
29+
setNativeScene: native.setNativeScene,
2930
subscribeNativeCompositor: () => () => undefined,
3031
useIsCpuCompositor: () => false,
3132
useNativeCompositorView: () => ({ viewId: 7, error: null }),
@@ -94,6 +95,29 @@ function makeDocument(): AxcutDocument {
9495
};
9596
}
9697

98+
function makeTrimmedDocument(): AxcutDocument {
99+
const document = makeDocument();
100+
return {
101+
...document,
102+
timeline: {
103+
...document.timeline,
104+
clips: [{ ...FIRST, sourceEndSec: 10, timelineEndSec: 10 }],
105+
trimRanges: [
106+
{
107+
id: "trim_a",
108+
assetId: "asset_take",
109+
clipId: "clip_a",
110+
startSec: 4,
111+
endSec: 6,
112+
reason: "",
113+
origin: "user",
114+
},
115+
],
116+
},
117+
legacyEditor: { speedRegions: [{ id: "speed_a", startMs: 0, endMs: 10_000, speed: 2 }] },
118+
};
119+
}
120+
97121
function setPlayhead(currentTimeSec: number, playing: boolean) {
98122
act(() => {
99123
useProjectStore.setState({ currentTimeSec, playing });
@@ -248,4 +272,34 @@ describe("NativeCompositorOverlay while playing", () => {
248272
expect(native.setNativePlaying).toHaveBeenCalledWith(false);
249273
expect(native.setActiveClip).toHaveBeenCalledTimes(1);
250274
});
275+
276+
it("uses an untrimmed 1x scene while recording and restores the edited scene afterward", async () => {
277+
useProjectStore.setState({ document: makeTrimmedDocument(), currentTimeSec: 8 });
278+
const view = render(<NativeCompositorOverlay />);
279+
await act(async () => {
280+
await Promise.resolve();
281+
});
282+
native.setActiveClip.mockClear();
283+
native.setNativeScene.mockClear();
284+
285+
view.rerender(<NativeCompositorOverlay playUneditedTimeline />);
286+
await act(async () => {
287+
await Promise.resolve();
288+
});
289+
const recordingScene = JSON.parse(native.setNativeScene.mock.lastCall?.[0] ?? "{}");
290+
expect(recordingScene.clips).toHaveLength(1);
291+
expect(recordingScene.speedRegions).toEqual([]);
292+
expect(native.setActiveClip).toHaveBeenCalledWith(7, "/take.mp4", "", 0, 0, 8);
293+
294+
native.setActiveClip.mockClear();
295+
native.setNativeScene.mockClear();
296+
view.rerender(<NativeCompositorOverlay />);
297+
await act(async () => {
298+
await Promise.resolve();
299+
});
300+
const editedScene = JSON.parse(native.setNativeScene.mock.lastCall?.[0] ?? "{}");
301+
expect(editedScene.clips).toHaveLength(2);
302+
expect(editedScene.speedRegions.length).toBeGreaterThan(0);
303+
expect(native.setActiveClip).toHaveBeenCalledWith(7, "/take.mp4", "", 0, 1, 8);
304+
});
251305
});

‎src/components/ai-edition/NativeCompositorOverlay.tsx‎

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,11 @@ import {
5555
* calques interactifs (zoom gimbal, annotations, drag webcam) qui restent des
5656
* éléments DOM cliquables au-dessus.
5757
*/
58-
export function NativeCompositorOverlay() {
58+
export function NativeCompositorOverlay({
59+
playUneditedTimeline = false,
60+
}: {
61+
playUneditedTimeline?: boolean;
62+
}) {
5963
const canvasRef = useRef<HTMLCanvasElement>(null);
6064
const previousActiveClipIdRef = useRef<string | null>(null);
6165
const document = useProjectStore((s) => s.document);
@@ -70,22 +74,25 @@ export function NativeCompositorOverlay() {
7074
() => 0,
7175
);
7276

73-
// `currentTimeSec` est en temps RAW/document (la règle où les trims occupent encore leur
74-
// place — même référentiel que le playhead V4 et l'overlay webcam web). Le natif, lui, joue
75-
// les segments COMPACTÉS (`resolveVisibleClips`, trims retirés) — le même flux que la SCÈNE
76-
// envoyée via `setNativeScene`. `resolveNativePosition` fait le pont : il mappe le playhead
77-
// RAW → temps source via le layout RAW (`document.timeline.clips`) PUIS localise le segment
78-
// compacté correspondant, d'où sortent le `clipIndex` natif et la bonne paire écran/webcam.
79-
// Sans ça (ancien `resolveNativePlaybackPosition(nativeClips, currentTimeSec)`), un playhead
80-
// RAW lu contre des clips compactés désignait le mauvais clip après un trim → mauvaise caméra
81-
// + décalage écran/cam.
82-
const nativeClips = useMemo(() => {
83-
if (!document) return [];
84-
return resolveVisibleClips(document);
85-
}, [document]);
77+
// `currentTimeSec` stays on the raw document ruler. During normal playback the native scene
78+
// uses trim-compressed segments; while recording, `playbackDocument` removes trims and speed
79+
// regions so the compositor shows the same unedited timeline as the video clock.
80+
const playbackDocument = useMemo(() => {
81+
if (!document || !playUneditedTimeline) return document;
82+
return {
83+
...document,
84+
timeline: { ...document.timeline, trimRanges: [] },
85+
legacyEditor: document.legacyEditor ? { ...document.legacyEditor, speedRegions: [] } : null,
86+
};
87+
}, [document, playUneditedTimeline]);
88+
const nativeClips = useMemo(
89+
() => (playbackDocument ? resolveVisibleClips(playbackDocument) : []),
90+
[playbackDocument],
91+
);
8692
const activePosition = useMemo(
87-
() => resolveNativePosition(currentTimeSec, nativeClips, document?.timeline.clips ?? []),
88-
[nativeClips, currentTimeSec, document],
93+
() =>
94+
resolveNativePosition(currentTimeSec, nativeClips, playbackDocument?.timeline.clips ?? []),
95+
[nativeClips, currentTimeSec, playbackDocument],
8996
);
9097
const activeClip = activePosition?.clip ?? null;
9198

@@ -149,12 +156,12 @@ export function NativeCompositorOverlay() {
149156
// quand on pousse.
150157
// biome-ignore lint/correctness/useExhaustiveDependencies: size revision
151158
useEffect(() => {
152-
if (viewId === null || !document) {
159+
if (viewId === null || !playbackDocument) {
153160
return;
154161
}
155162
try {
156163
const webcamSourceSize = cameraPath ? getWebcamNativeSize(cameraPath) : null;
157-
const scene = buildSceneDescription(document, webcamSourceSize);
164+
const scene = buildSceneDescription(playbackDocument, webcamSourceSize);
158165
setNativeScene(JSON.stringify(scene));
159166
} catch (error) {
160167
console.warn("[compositor-view] build/push scene failed:", error);
@@ -164,7 +171,7 @@ export function NativeCompositorOverlay() {
164171
// re-read fresh via getWebcamNativeSize() above on every run (biome flags this as
165172
// an "unnecessary" dependency, but removing it would mean a probed webcam size
166173
// arriving after mount never gets pushed to native).
167-
}, [viewId, document, sources, _webcamSizeRevision]);
174+
}, [viewId, playbackDocument, sources, _webcamSizeRevision]);
168175

169176
// SYNCHRO COMPLETE DES PARAMS, en un seul endroit.
170177
//
@@ -195,14 +202,17 @@ export function NativeCompositorOverlay() {
195202
const activeSourceTimeSec = activePosition?.sourceTimeSec ?? null;
196203
// Matched on the raw ruler, like `currentTimeSec` and the regions' pills.
197204
const speedRegions = useMemo(
198-
() => (document ? readSpeedRegions<SpeedRegion>(document) : []),
199-
[document],
205+
() => (playbackDocument ? readSpeedRegions<SpeedRegion>(playbackDocument) : []),
206+
[playbackDocument],
200207
);
201-
const playbackSpeed = findActiveSpeedRegion(speedRegions, currentTimeSec * 1000)?.speed ?? 1;
208+
const playbackSpeed = playUneditedTimeline
209+
? 1
210+
: (findActiveSpeedRegion(speedRegions, currentTimeSec * 1000)?.speed ?? 1);
202211
const pendingTargetClipIdRef = useRef<string | null>(null);
203212

204213
const playing = useProjectStore((s) => s.playing);
205214
const wasPlayingRef = useRef(playing);
215+
const previousUneditedTimelineRef = useRef(playUneditedTimeline);
206216

207217
// Change les décodeurs screen/webcam quand le playhead entre dans un autre clip, à l'arrêt.
208218
//
@@ -214,6 +224,8 @@ export function NativeCompositorOverlay() {
214224
useEffect(() => {
215225
const justPaused = wasPlayingRef.current && !playing;
216226
wasPlayingRef.current = playing;
227+
const playbackModeChanged = previousUneditedTimelineRef.current !== playUneditedTimeline;
228+
previousUneditedTimelineRef.current = playUneditedTimeline;
217229
if (
218230
viewId === null ||
219231
!document ||
@@ -230,7 +242,7 @@ export function NativeCompositorOverlay() {
230242
// y retourne, sans quoi le recalage en temps (`setNativeTime`) chercherait dans le
231243
// mauvais fichier.
232244
const nativeElsewhere = justPaused && native !== null && native.clipIndex !== activeClipIndex;
233-
if (!clipChanged && !nativeElsewhere) {
245+
if (!clipChanged && !playbackModeChanged && !nativeElsewhere) {
234246
return;
235247
}
236248
const asset = document.assets.find((candidate) => candidate.id === activeClip.assetId);
@@ -305,6 +317,7 @@ export function NativeCompositorOverlay() {
305317
activeClipIndex,
306318
activeSourceTimeSec,
307319
playing,
320+
playUneditedTimeline,
308321
nativeClips,
309322
playbackSpeed,
310323
]);

‎src/components/ai-edition/NewEditorShell.tsx‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,22 @@ const NO_AUDIO_TRACKS: AxcutAudioTrack[] = [];
106106
function NativePlaybackSync({
107107
visibleClips,
108108
clips,
109+
playUneditedTimeline,
109110
}: {
110111
visibleClips: AxcutClip[];
111112
clips: AxcutClip[];
113+
playUneditedTimeline: boolean;
112114
}) {
113115
const playing = useProjectStore((s) => s.playing);
114116
const currentTimeSec = useProjectStore((s) => s.currentTimeSec);
115-
// visibleClips = trim-compressed native stream; `clips` = RAW layout currentTimeSec
116-
// is measured against. resolveNativePosition needs both (see timelineMap).
117-
useNativePlaybackSync(playing, currentTimeSec, visibleClips, clips);
117+
// The native stream follows the edited timeline except while a voiceover records.
118+
// `currentTimeSec` always stays on the raw ruler, including during playback.
119+
useNativePlaybackSync(
120+
playing,
121+
currentTimeSec,
122+
playUneditedTimeline ? clips : visibleClips,
123+
clips,
124+
);
118125
return null;
119126
}
120127

@@ -1012,12 +1019,20 @@ export function NewEditorShell() {
10121019
// where it is passed to the preview.
10131020
const [voiceoverRecording, setVoiceoverRecording] = useState(false);
10141021

1015-
const handleVoiceoverRecordingStart = useCallback(() => {
1022+
const handleVoiceoverRecordingStart = useCallback(async () => {
10161023
// Re-capture: the user may have scrubbed between opening the dialog and
10171024
// hitting Record, and playback starts from wherever the playhead is now.
1018-
voiceoverStartSecRef.current = useProjectStore.getState().currentTimeSec;
1025+
const doc = useProjectStore.getState().document;
1026+
const playhead = useProjectStore.getState().currentTimeSec;
1027+
voiceoverStartSecRef.current = playhead;
1028+
if (doc) {
1029+
const total = doc.timeline.clips.reduce((max, c) => Math.max(max, c.timelineEndSec), 0);
1030+
setVoiceoverFlow({ maxDurationSec: Math.max(0.5, total - playhead) });
1031+
}
10191032
setVoiceoverRecording(true);
1020-
if (videoElement?.paused) void videoElement.play().catch(() => undefined);
1033+
if (!videoElement) throw new Error("The preview is not ready.");
1034+
videoElement.playbackRate = 1;
1035+
if (videoElement.paused) await videoElement.play();
10211036
}, [videoElement]);
10221037

10231038
const handleVoiceoverRecordingStop = useCallback(() => {
@@ -1028,7 +1043,8 @@ export function NewEditorShell() {
10281043
const handleVoiceoverReady = useCallback(
10291044
async (assetId: string, durationSec: number) => {
10301045
setVoiceoverFlow(null);
1031-
await tl.addAudioTrack(assetId, voiceoverStartSecRef.current, {
1046+
const startSec = voiceoverStartSecRef.current;
1047+
await tl.addAudioTrack(assetId, startSec, {
10321048
kind: "voiceover",
10331049
durationSec,
10341050
spanSec: durationSec,
@@ -1558,7 +1574,11 @@ export function NewEditorShell() {
15581574
className={v4.app}
15591575
style={{ gridTemplateRows: `58px 1fr ${showTimeline ? timelineRow : "0px"}` }}
15601576
>
1561-
<NativePlaybackSync visibleClips={visibleClips} clips={clips} />
1577+
<NativePlaybackSync
1578+
visibleClips={visibleClips}
1579+
clips={clips}
1580+
playUneditedTimeline={voiceoverRecording}
1581+
/>
15621582
<EditorTopBar
15631583
mode={mode}
15641584
onModeChange={setMode}
@@ -1653,6 +1673,7 @@ export function NewEditorShell() {
16531673
clips={clips}
16541674
zoomRegions={tl.zoomRegions}
16551675
speedRegions={tl.speedRegions}
1676+
playUneditedTimeline={voiceoverRecording}
16561677
cameraFullscreenRegions={tl.cameraFullscreenRegions}
16571678
trimRanges={tl.trimRanges}
16581679
selectedZoomRegionId={tl.selection?.kind === "zoom" ? tl.selection.id : null}

0 commit comments

Comments
 (0)