Skip to content

Commit a65830e

Browse files
committed
fix(editor): wait for preview before voiceover recording
1 parent a1b92b3 commit a65830e

4 files changed

Lines changed: 80 additions & 15 deletions

File tree

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,7 +1015,7 @@ export function NewEditorShell() {
10151015
// where it is passed to the preview.
10161016
const [voiceoverRecording, setVoiceoverRecording] = useState(false);
10171017

1018-
const handleVoiceoverRecordingStart = useCallback(() => {
1018+
const handleVoiceoverRecordingStart = useCallback(async () => {
10191019
// Re-capture: the user may have scrubbed between opening the dialog and
10201020
// hitting Record, and playback starts from wherever the playhead is now.
10211021
const doc = useProjectStore.getState().document;
@@ -1026,10 +1026,9 @@ export function NewEditorShell() {
10261026
setVoiceoverFlow({ maxDurationSec: Math.max(0.5, total - playhead) });
10271027
}
10281028
setVoiceoverRecording(true);
1029-
if (videoElement) {
1030-
videoElement.playbackRate = 1;
1031-
if (videoElement.paused) void videoElement.play().catch(() => undefined);
1032-
}
1029+
if (!videoElement) throw new Error("The preview is not ready.");
1030+
videoElement.playbackRate = 1;
1031+
if (videoElement.paused) await videoElement.play();
10331032
}, [videoElement]);
10341033

10351034
const handleVoiceoverRecordingStop = useCallback(() => {

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

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { buildSceneDescription } from "@/native/sceneDescription";
1212

1313
const mocks = vi.hoisted(() => ({
1414
nativePlaybackSync: vi.fn(),
15+
previewPlay: vi.fn(async () => undefined),
1516
}));
1617

1718
vi.mock("@/contexts/ShortcutsContext", async () => {
@@ -38,13 +39,31 @@ vi.mock("@/native/useNativePlaybackSync", () => ({
3839
useNativePlaybackSync: mocks.nativePlaybackSync,
3940
}));
4041

41-
vi.mock("./Preview", () => ({
42-
Preview: ({ playUneditedTimeline }: { playUneditedTimeline?: boolean }) =>
43-
createElement("div", {
44-
"data-testid": "preview-recording-mode",
45-
"data-unedited": String(playUneditedTimeline ?? false),
46-
}),
47-
}));
42+
vi.mock("./Preview", async () => {
43+
const { createElement, useEffect } = await import("react");
44+
return {
45+
Preview: ({
46+
playUneditedTimeline,
47+
onVideoElement,
48+
}: {
49+
playUneditedTimeline?: boolean;
50+
onVideoElement: (element: HTMLVideoElement | null) => void;
51+
}) => {
52+
useEffect(() => {
53+
const element = document.createElement("video");
54+
Object.defineProperty(element, "paused", { configurable: true, value: true });
55+
element.play = mocks.previewPlay;
56+
element.pause = vi.fn();
57+
onVideoElement(element);
58+
return () => onVideoElement(null);
59+
}, [onVideoElement]);
60+
return createElement("div", {
61+
"data-testid": "preview-recording-mode",
62+
"data-unedited": String(playUneditedTimeline ?? false),
63+
});
64+
},
65+
};
66+
});
4867

4968
vi.mock("./v4/V4Timeline", () => ({
5069
V4Timeline: ({ onAddVoiceover }: { onAddVoiceover: () => void }) =>
@@ -60,7 +79,7 @@ vi.mock("./v4/AddAudioLayerDialog", () => ({
6079
}: {
6180
open: boolean;
6281
onComplete: (assetId: string, durationSec: number) => void;
63-
onRecordingStart: () => void;
82+
onRecordingStart: () => void | Promise<void>;
6483
onRecordingStop: () => void;
6584
}) =>
6685
open
@@ -200,6 +219,7 @@ describe("NewEditorShell voiceover placement", () => {
200219
fireEvent.click(screen.getByRole("button", { name: "start-recording" }));
201220

202221
expect(screen.getByTestId("preview-recording-mode")).toHaveAttribute("data-unedited", "true");
222+
expect(mocks.previewPlay).toHaveBeenCalledOnce();
203223
expect(mocks.nativePlaybackSync).toHaveBeenLastCalledWith(false, 2, rawClips, rawClips);
204224

205225
act(() => {

‎src/components/ai-edition/v4/AddAudioLayerDialog.test.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
// @vitest-environment jsdom
22
import "@testing-library/jest-dom";
33
import { fireEvent, render, screen } from "@testing-library/react";
4+
import { toast } from "sonner";
45
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
56
import { AddAudioLayerDialog } from "./AddAudioLayerDialog";
67

8+
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
9+
710
vi.mock("@/contexts/I18nContext", () => ({
811
useScopedT: () => (key: string) => key,
912
useI18n: () => ({ locale: "en", setLocale: () => undefined }),
@@ -24,6 +27,7 @@ vi.mock("@/lib/ai-edition/timeline/duration", () => ({
2427
class FakeRecorder {
2528
static instances: FakeRecorder[] = [];
2629
state: "inactive" | "recording" = "inactive";
30+
started = false;
2731
stopped = false;
2832
ondataavailable: ((e: { data: Blob }) => void) | null = null;
2933
onstop: (() => void) | null = null;
@@ -35,6 +39,7 @@ class FakeRecorder {
3539
return true;
3640
}
3741
start() {
42+
this.started = true;
3843
this.state = "recording";
3944
}
4045
stop() {
@@ -50,6 +55,7 @@ beforeEach(() => {
5055
FakeRecorder.instances = [];
5156
stopTrack.mockClear();
5257
addAudioAsset.mockReset();
58+
vi.mocked(toast.error).mockClear();
5359
vi.stubGlobal("MediaRecorder", FakeRecorder);
5460
Object.defineProperty(navigator, "mediaDevices", {
5561
configurable: true,
@@ -85,6 +91,37 @@ describe("AddAudioLayerDialog", () => {
8591
await vi.waitFor(() => expect(props.onRecordingStart).toHaveBeenCalledTimes(1));
8692
});
8793

94+
it("waits for preview playback before starting the recorder", async () => {
95+
let allowStart: (() => void) | undefined;
96+
const onRecordingStart = vi.fn(
97+
() =>
98+
new Promise<void>((resolve) => {
99+
allowStart = resolve;
100+
}),
101+
);
102+
renderDialog({ onRecordingStart });
103+
104+
fireEvent.click(screen.getByText("audio.record"));
105+
await vi.waitFor(() => expect(onRecordingStart).toHaveBeenCalledOnce());
106+
expect(FakeRecorder.instances[0].started).toBe(false);
107+
108+
allowStart?.();
109+
await vi.waitFor(() => expect(FakeRecorder.instances[0].started).toBe(true));
110+
});
111+
112+
it("releases the microphone and aborts when preview playback fails", async () => {
113+
const onRecordingStart = vi.fn().mockRejectedValue(new Error("playback failed"));
114+
const { props } = renderDialog({ onRecordingStart });
115+
116+
fireEvent.click(screen.getByText("audio.record"));
117+
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith("audio.recordingUnavailable"));
118+
119+
expect(FakeRecorder.instances).toHaveLength(1);
120+
expect(FakeRecorder.instances[0].started).toBe(false);
121+
expect(stopTrack).toHaveBeenCalledOnce();
122+
expect(props.onRecordingStop).toHaveBeenCalledOnce();
123+
});
124+
88125
it("does not cover the video it is recording against", () => {
89126
// It used to be a modal over a dimmed backdrop, which hid the one thing a
90127
// voiceover needs you to watch. It is a docked bar now: no backdrop, and

‎src/components/ai-edition/v4/AddAudioLayerDialog.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export function AddAudioLayerDialog({
5454
maxDurationSec: number;
5555
onClose: () => void;
5656
onComplete: (assetId: string, durationSec: number) => void;
57-
onRecordingStart: () => void;
57+
onRecordingStart: () => void | Promise<void>;
5858
onRecordingStop: () => void;
5959
}) {
6060
const t = useScopedT("timeline");
@@ -212,13 +212,22 @@ export function AddAudioLayerDialog({
212212
}
213213
})();
214214
};
215+
try {
216+
await onRecordingStart();
217+
} catch {
218+
for (const track of streamRef.current?.getTracks() ?? []) track.stop();
219+
streamRef.current = null;
220+
recorderRef.current = null;
221+
onRecordingStop();
222+
toast.error(t("audio.recordingUnavailable"));
223+
return;
224+
}
215225
startedAtRef.current = performance.now();
216226
discardRef.current = false;
217227
recorder.start(250);
218228
setRecording(true);
219229
recordingRef.current = true;
220230
setElapsedSec(0);
221-
onRecordingStart();
222231
timerRef.current = setInterval(() => {
223232
setElapsedSec((performance.now() - startedAtRef.current) / 1000);
224233
}, 200);

0 commit comments

Comments
 (0)