From 464dc19f64e6aed2320f5fcf494928523601c601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E9=94=9F?= Date: Fri, 6 Nov 2026 21:53:27 +0800 Subject: [PATCH 1/5] feat: pause and resume voiceover recording --- src/components/ai-edition/NewEditorShell.tsx | 36 +++- .../NewEditorShell.voiceover.test.tsx | 179 +++++++++++++----- .../v4/AddAudioLayerDialog.test.tsx | 153 ++++++++++++++- .../ai-edition/v4/AddAudioLayerDialog.tsx | 89 ++++++++- src/i18n/locales/ar/timeline.json | 4 + src/i18n/locales/cs/timeline.json | 4 + src/i18n/locales/de/timeline.json | 4 + src/i18n/locales/en/timeline.json | 4 + src/i18n/locales/es/timeline.json | 4 + src/i18n/locales/fr/timeline.json | 4 + src/i18n/locales/it/timeline.json | 4 + src/i18n/locales/ja-JP/timeline.json | 4 + src/i18n/locales/ko-KR/timeline.json | 4 + src/i18n/locales/pt-BR/timeline.json | 4 + src/i18n/locales/ru/timeline.json | 4 + src/i18n/locales/tr/timeline.json | 4 + src/i18n/locales/vi/timeline.json | 4 + src/i18n/locales/zh-CN/timeline.json | 4 + src/i18n/locales/zh-TW/timeline.json | 4 + .../testing/manual-e2e-checklist.md | 1 + 20 files changed, 460 insertions(+), 58 deletions(-) diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 2a417df39..e46c4a9ed 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -223,6 +223,9 @@ export function NewEditorShell() { const [seekTarget, setSeekTarget] = useState(null); const [videoElement, setVideoElement] = useState(null); + const voiceoverPausedRef = useRef(false); + const voiceoverActiveRef = useRef(false); + const voiceoverSessionRef = useRef(0); // v4 shell: three modes (Media / Edit / Rec), a collapsible agent (chat) // column, and a floating facet inspector over the stage. const [mode, setMode] = useState("edit"); @@ -542,6 +545,8 @@ export function NewEditorShell() { const handleSeek = useCallback( (timeSec: number, preservePlayback?: boolean) => { + // A paused take must resume against the same picture, not a scrubbed-to position. + if (voiceoverPausedRef.current) return; setCurrentTime(timeSec); setSeekTarget({ timeSec, @@ -645,6 +650,8 @@ export function NewEditorShell() { }, [videoElement, setPlaying]); const togglePlay = useCallback(() => { + // Resume the microphone and picture together from the voiceover bar. + if (voiceoverPausedRef.current) return; if (!videoElement) return; if (videoElement.paused) { const playhead = useProjectStore.getState().currentTimeSec; @@ -1058,6 +1065,9 @@ export function NewEditorShell() { const [voiceoverRecording, setVoiceoverRecording] = useState(false); const handleVoiceoverRecordingStart = useCallback(async () => { + voiceoverSessionRef.current++; + voiceoverActiveRef.current = true; + voiceoverPausedRef.current = false; // Re-capture: the user may have scrubbed between opening the dialog and // hitting Record, and playback starts from wherever the playhead is now. const doc = useProjectStore.getState().document; @@ -1074,10 +1084,32 @@ export function NewEditorShell() { }, [videoElement]); const handleVoiceoverRecordingStop = useCallback(() => { + voiceoverSessionRef.current++; + voiceoverActiveRef.current = false; + voiceoverPausedRef.current = false; setVoiceoverRecording(false); videoElement?.pause(); }, [videoElement]); + const handleVoiceoverRecordingPause = useCallback(() => { + voiceoverPausedRef.current = true; + videoElement?.pause(); + // Keep voiceoverRecording true: the take still owns the unedited clock and mute. + }, [videoElement]); + + const handleVoiceoverRecordingResume = useCallback(async () => { + if (!voiceoverActiveRef.current) return; + if (!videoElement) throw new Error("The preview is not ready."); + const session = voiceoverSessionRef.current; + await videoElement.play(); + if (session !== voiceoverSessionRef.current) { + // A pending play() must not restart the picture after Stop/Cancel. + if (!voiceoverActiveRef.current) videoElement.pause(); + return; + } + voiceoverPausedRef.current = false; + }, [videoElement]); + const handleVoiceoverReady = useCallback( async (assetId: string, durationSec: number) => { setVoiceoverFlow(null); @@ -1881,7 +1913,7 @@ export function NewEditorShell() { onClose={() => { // Belt and braces: the recorder's own stop handler clears this, but a // flow that ends any other way must not leave the timeline muted. - setVoiceoverRecording(false); + handleVoiceoverRecordingStop(); setVoiceoverFlow(null); }} onComplete={(assetId, durationSec) => { @@ -1889,6 +1921,8 @@ export function NewEditorShell() { }} onRecordingStart={handleVoiceoverRecordingStart} onRecordingStop={handleVoiceoverRecordingStop} + onRecordingPause={handleVoiceoverRecordingPause} + onRecordingResume={handleVoiceoverRecordingResume} /> ); diff --git a/src/components/ai-edition/NewEditorShell.voiceover.test.tsx b/src/components/ai-edition/NewEditorShell.voiceover.test.tsx index 8f43df0f3..dcb9a911b 100644 --- a/src/components/ai-edition/NewEditorShell.voiceover.test.tsx +++ b/src/components/ai-edition/NewEditorShell.voiceover.test.tsx @@ -12,7 +12,8 @@ import { buildSceneDescription } from "@/native/sceneDescription"; const mocks = vi.hoisted(() => ({ nativePlaybackSync: vi.fn(), - previewPlay: vi.fn(async () => undefined), + previewPlay: vi.fn(async (): Promise => undefined), + previewPause: vi.fn(), })); vi.mock("@/contexts/ShortcutsContext", async () => { @@ -53,7 +54,7 @@ vi.mock("./Preview", async () => { const element = document.createElement("video"); Object.defineProperty(element, "paused", { configurable: true, value: true }); element.play = mocks.previewPlay; - element.pause = vi.fn(); + element.pause = mocks.previewPause; onVideoElement(element); return () => onVideoElement(null); }, [onVideoElement]); @@ -66,8 +67,22 @@ vi.mock("./Preview", async () => { }); vi.mock("./v4/V4Timeline", () => ({ - V4Timeline: ({ onAddVoiceover }: { onAddVoiceover: () => void }) => - createElement("button", { type: "button", onClick: onAddVoiceover }, "open-voiceover"), + V4Timeline: ({ + onAddVoiceover, + setCurrentTime, + onTogglePlay, + }: { + onAddVoiceover: () => void; + setCurrentTime: (time: number) => void; + onTogglePlay: () => void; + }) => + createElement( + "div", + null, + createElement("button", { type: "button", onClick: onAddVoiceover }, "open-voiceover"), + createElement("button", { type: "button", onClick: () => setCurrentTime(9) }, "seek-preview"), + createElement("button", { type: "button", onClick: onTogglePlay }, "toggle-preview"), + ), })); vi.mock("./v4/AddAudioLayerDialog", () => ({ @@ -76,11 +91,15 @@ vi.mock("./v4/AddAudioLayerDialog", () => ({ onComplete, onRecordingStart, onRecordingStop, + onRecordingPause, + onRecordingResume, }: { open: boolean; onComplete: (assetId: string, durationSec: number) => void; onRecordingStart: () => void | Promise; onRecordingStop: () => void; + onRecordingPause: () => void; + onRecordingResume: () => void | Promise; }) => open ? createElement( @@ -88,6 +107,12 @@ vi.mock("./v4/AddAudioLayerDialog", () => ({ null, createElement("button", { type: "button", onClick: onRecordingStart }, "start-recording"), createElement("button", { type: "button", onClick: onRecordingStop }, "stop-recording"), + createElement("button", { type: "button", onClick: onRecordingPause }, "pause-recording"), + createElement( + "button", + { type: "button", onClick: onRecordingResume }, + "resume-recording", + ), createElement( "button", { type: "button", onClick: () => onComplete("voice", 4) }, @@ -155,52 +180,54 @@ function renderShell() { ); } -describe("NewEditorShell voiceover placement", () => { - beforeEach(() => { - localStorage.clear(); - vi.clearAllMocks(); - (window as unknown as { electronAPI?: unknown }).electronAPI = { - onAiEditionChatEvent: () => () => undefined, - sendAiEditionChatPrompt: () => undefined, - setTitleBarOverlay: () => undefined, - setHasUnsavedChanges: () => undefined, - onRequestCloseConfirm: () => () => undefined, - onRequestSaveBeforeClose: () => () => undefined, - sendCloseConfirmResponse: () => undefined, - findRecordingCamera: () => Promise.resolve(null), - preparePreviewAudioTrack: () => Promise.resolve(null), - isAppPackaged: () => false, - }; - Element.prototype.scrollTo = () => undefined; - (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class { - observe = vi.fn(); - unobserve = vi.fn(); - disconnect = vi.fn(); - }; - const document = makeDocument(); - const saveDocument = vi.fn(async (next: AxcutDocument) => { - useProjectStore.setState({ - document: next, - revision: 2, - dirty: true, - lastSavedAt: new Date(), - }); - return true; - }); +function prepareShell() { + localStorage.clear(); + vi.clearAllMocks(); + (window as unknown as { electronAPI?: unknown }).electronAPI = { + onAiEditionChatEvent: () => () => undefined, + sendAiEditionChatPrompt: () => undefined, + setTitleBarOverlay: () => undefined, + setHasUnsavedChanges: () => undefined, + onRequestCloseConfirm: () => () => undefined, + onRequestSaveBeforeClose: () => () => undefined, + sendCloseConfirmResponse: () => undefined, + findRecordingCamera: () => Promise.resolve(null), + preparePreviewAudioTrack: () => Promise.resolve(null), + isAppPackaged: () => false, + }; + Element.prototype.scrollTo = () => undefined; + (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); + }; + const document = makeDocument(); + const saveDocument = vi.fn(async (next: AxcutDocument) => { useProjectStore.setState({ - projectId: document.project.id, - document, - revision: 1, - status: "ready", - error: null, - sourceDurationSec: 10, - currentTimeSec: 2, - playing: false, - dirty: false, + document: next, + revision: 2, + dirty: true, lastSavedAt: new Date(), - saveDocument, }); + return true; + }); + useProjectStore.setState({ + projectId: document.project.id, + document, + revision: 1, + status: "ready", + error: null, + sourceDurationSec: 10, + currentTimeSec: 2, + playing: false, + dirty: false, + lastSavedAt: new Date(), + saveDocument, }); +} + +describe("NewEditorShell voiceover placement", () => { + beforeEach(prepareShell); afterEach(() => { cleanup(); @@ -249,3 +276,63 @@ describe("NewEditorShell voiceover placement", () => { ]); }); }); + +describe("paused voiceover preview", () => { + beforeEach(prepareShell); + afterEach(() => { + cleanup(); + useProjectStore.getState().clear(); + }); + + it("freezes seeking and transport while paused, then keeps the original placement across resume", async () => { + renderShell(); + fireEvent.click(screen.getByText("open-voiceover")); + await act(async () => { + fireEvent.click(screen.getByText("start-recording")); + }); + act(() => { + useProjectStore.setState({ currentTimeSec: 4 }); + }); + fireEvent.click(screen.getByText("pause-recording")); + expect(mocks.previewPause).toHaveBeenCalledOnce(); + expect(screen.getByTestId("preview-recording-mode")).toHaveAttribute("data-unedited", "true"); + fireEvent.click(screen.getByText("seek-preview")); + fireEvent.click(screen.getByText("toggle-preview")); + expect(useProjectStore.getState().currentTimeSec).toBe(4); + expect(mocks.previewPlay).toHaveBeenCalledOnce(); + await act(async () => { + fireEvent.click(screen.getByText("resume-recording")); + }); + expect(mocks.previewPlay).toHaveBeenCalledTimes(2); + fireEvent.click(screen.getByText("stop-recording")); + fireEvent.click(screen.getByText("complete-recording")); + await waitFor(() => expect(useProjectStore.getState().document?.audioTracks).toHaveLength(1)); + expect(useProjectStore.getState().document?.audioTracks[0]).toMatchObject({ + startMs: 2000, + endMs: 6000, + }); + }); + + it("re-pauses late playback if the take was stopped during an asynchronous resume", async () => { + renderShell(); + fireEvent.click(screen.getByText("open-voiceover")); + await act(async () => { + fireEvent.click(screen.getByText("start-recording")); + }); + fireEvent.click(screen.getByText("pause-recording")); + let finish!: () => void; + mocks.previewPlay.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + fireEvent.click(screen.getByText("resume-recording")); + fireEvent.click(screen.getByText("stop-recording")); + await act(async () => { + finish(); + }); + expect(mocks.previewPause).toHaveBeenCalledTimes(3); + expect(screen.getByTestId("preview-recording-mode")).toHaveAttribute("data-unedited", "false"); + }); +}); diff --git a/src/components/ai-edition/v4/AddAudioLayerDialog.test.tsx b/src/components/ai-edition/v4/AddAudioLayerDialog.test.tsx index 47277c782..cd527cdc5 100644 --- a/src/components/ai-edition/v4/AddAudioLayerDialog.test.tsx +++ b/src/components/ai-edition/v4/AddAudioLayerDialog.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { Blob as NodeBlob } from "node:buffer"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { toast } from "sonner"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AddAudioLayerDialog } from "./AddAudioLayerDialog"; @@ -26,7 +27,9 @@ vi.mock("@/lib/ai-edition/timeline/duration", () => ({ /** A MediaRecorder stand-in that records whether it was ever stopped. */ class FakeRecorder { static instances: FakeRecorder[] = []; - state: "inactive" | "recording" = "inactive"; + state: "inactive" | "recording" | "paused" = "inactive"; + pauseCount = 0; + resumeCount = 0; started = false; stopped = false; ondataavailable: ((e: { data: Blob }) => void) | null = null; @@ -42,9 +45,18 @@ class FakeRecorder { this.started = true; this.state = "recording"; } + pause() { + this.state = "paused"; + this.pauseCount++; + } + resume() { + this.state = "recording"; + this.resumeCount++; + } stop() { this.stopped = true; this.state = "inactive"; + this.ondataavailable?.({ data: new Blob(["audio"]) }); this.onstop?.(); } } @@ -57,6 +69,12 @@ beforeEach(() => { addAudioAsset.mockReset(); vi.mocked(toast.error).mockClear(); vi.stubGlobal("MediaRecorder", FakeRecorder); + vi.stubGlobal("Blob", NodeBlob); + Object.defineProperty(window, "electronAPI", { + configurable: true, + value: { saveRecordedVoiceover: vi.fn(async () => ({ success: true, path: "/voice.webm" })) }, + }); + addAudioAsset.mockResolvedValue({ id: "voice" }); Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [{ stop: stopTrack }] })) }, @@ -64,6 +82,9 @@ beforeEach(() => { }); afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -75,6 +96,8 @@ function renderDialog(over: Partial[0]> = onComplete: vi.fn(), onRecordingStart: vi.fn(), onRecordingStop: vi.fn(), + onRecordingPause: vi.fn(), + onRecordingResume: vi.fn(async () => undefined), ...over, }; const view = render(); @@ -165,3 +188,129 @@ describe("AddAudioLayerDialog", () => { expect(addAudioAsset).not.toHaveBeenCalled(); }); }); + +describe("voiceover pause and resume", () => { + let now = 0; + beforeEach(() => { + now = 0; + vi.spyOn(performance, "now").mockImplementation(() => now); + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + }); + async function start(over: Partial[0]> = {}) { + const result = renderDialog(over); + await act(async () => { + fireEvent.click(screen.getByText("audio.record")); + }); + return result; + } + function tick(at: number) { + now = at; + act(() => { + vi.advanceTimersByTime(200); + }); + } + + it("freezes its clock across repeated pauses and saves one take with only active time", async () => { + const { props } = await start({ maxDurationSec: 6 }); + tick(3000); + fireEvent.click(screen.getByRole("button", { name: "audio.pause" })); + expect(FakeRecorder.instances[0].state).toBe("paused"); + expect(props.onRecordingPause).toHaveBeenCalledOnce(); + tick(13000); + expect(screen.getByText("audio.paused 3.0s")).toBeInTheDocument(); + expect(FakeRecorder.instances[0].stopped).toBe(false); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "audio.resume" })); + }); + tick(14000); + fireEvent.click(screen.getByRole("button", { name: "audio.pause" })); + tick(24000); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "audio.resume" })); + }); + tick(25000); + await act(async () => { + fireEvent.click(screen.getByText("audio.stop")); + }); + expect(props.onComplete).toHaveBeenCalledWith("voice", 5); + expect(addAudioAsset).toHaveBeenCalledOnce(); + expect(FakeRecorder.instances).toHaveLength(1); + expect(props.onRecordingStart).toHaveBeenCalledOnce(); + expect(props.onRecordingResume).toHaveBeenCalledTimes(2); + }); + + it("stops and saves a paused take without including the wait", async () => { + const { props } = await start(); + tick(2000); + fireEvent.click(screen.getByRole("button", { name: "audio.pause" })); + tick(22000); + await act(async () => { + fireEvent.click(screen.getByText("audio.stop")); + }); + expect(props.onComplete).toHaveBeenCalledWith("voice", 2); + expect(stopTrack).toHaveBeenCalledOnce(); + }); + + it.each([ + "cancel", + "escape", + "unmount", + ])("discards and releases a paused take on %s", async (how) => { + const { unmount, props } = await start(); + fireEvent.click(screen.getByRole("button", { name: "audio.pause" })); + if (how === "unmount") unmount(); + else if (how === "escape") fireEvent.keyDown(window, { key: "Escape" }); + else fireEvent.click(screen.getByText("actions.cancel")); + expect(FakeRecorder.instances[0].stopped).toBe(true); + expect(stopTrack).toHaveBeenCalled(); + expect(props.onRecordingStop).toHaveBeenCalled(); + expect(addAudioAsset).not.toHaveBeenCalled(); + }); + + it("leaves the recorder paused on preview resume failure, and permits retry", async () => { + const onRecordingResume = vi + .fn() + .mockRejectedValueOnce(new Error("play blocked")) + .mockResolvedValue(undefined); + const { props } = await start({ onRecordingResume }); + tick(1000); + fireEvent.click(screen.getByRole("button", { name: "audio.pause" })); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "audio.resume" })); + }); + expect(FakeRecorder.instances[0].state).toBe("paused"); + expect(toast.error).toHaveBeenCalledWith("audio.recordingUnavailable"); + tick(11000); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "audio.resume" })); + }); + tick(12000); + await act(async () => { + fireEvent.click(screen.getByText("audio.stop")); + }); + expect(props.onComplete).toHaveBeenCalledWith("voice", 2); + }); + + it("does not revive a take stopped while preview resume is pending", async () => { + let finishResume!: () => void; + const onRecordingResume = vi.fn( + () => + new Promise((resolve) => { + finishResume = resolve; + }), + ); + const { props } = await start({ onRecordingResume }); + tick(1000); + fireEvent.click(screen.getByRole("button", { name: "audio.pause" })); + fireEvent.click(screen.getByRole("button", { name: "audio.resume" })); + expect(screen.getByRole("button", { name: "audio.resume" })).toBeDisabled(); + await act(async () => { + fireEvent.click(screen.getByText("audio.stop")); + }); + await act(async () => { + finishResume(); + }); + expect(FakeRecorder.instances[0].resumeCount).toBe(0); + expect(props.onComplete).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/ai-edition/v4/AddAudioLayerDialog.tsx b/src/components/ai-edition/v4/AddAudioLayerDialog.tsx index 0ee457989..695f3ee45 100644 --- a/src/components/ai-edition/v4/AddAudioLayerDialog.tsx +++ b/src/components/ai-edition/v4/AddAudioLayerDialog.tsx @@ -10,7 +10,7 @@ // `onComplete` — placing the track on the timeline (span, anchor, inspector // selection) is the caller's job, exactly like the other add* flows. -import { Mic, StopCircle, Upload } from "lucide-react"; +import { Mic, Pause, Play, StopCircle, Upload } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { toFileUrl } from "@/components/video-editor/projectPersistence"; @@ -49,6 +49,8 @@ export function AddAudioLayerDialog({ onComplete, onRecordingStart, onRecordingStop, + onRecordingPause, + onRecordingResume, }: { open: boolean; maxDurationSec: number; @@ -56,16 +58,21 @@ export function AddAudioLayerDialog({ onComplete: (assetId: string, durationSec: number) => void; onRecordingStart: () => void | Promise; onRecordingStop: () => void; + onRecordingPause: () => void; + onRecordingResume: () => void | Promise; }) { const t = useScopedT("timeline"); const tc = useScopedT("common"); const [busy, setBusy] = useState(false); const [recording, setRecording] = useState(false); + const [paused, setPaused] = useState(false); const [elapsedSec, setElapsedSec] = useState(0); const recorderRef = useRef(null); const streamRef = useRef(null); const chunksRef = useRef([]); - const startedAtRef = useRef(0); + const startedAtRef = useRef(null); + const activeDurationMsRef = useRef(0); + const resumingRef = useRef(false); const timerRef = useRef | null>(null); // Set when the user cancels (closes the dialog mid-take) — the stop handler // then discards the blob instead of importing it as a layer. @@ -73,6 +80,17 @@ export function AddAudioLayerDialog({ // Read by the Escape handler, which must not re-subscribe every time the // elapsed-time state ticks. const recordingRef = useRef(false); + const elapsed = useCallback(() => { + const liveMs = startedAtRef.current === null ? 0 : performance.now() - startedAtRef.current; + return (activeDurationMsRef.current + liveMs) / 1000; + }, []); + const freezeClock = useCallback(() => { + if (startedAtRef.current !== null) { + activeDurationMsRef.current += performance.now() - startedAtRef.current; + startedAtRef.current = null; + } + return activeDurationMsRef.current / 1000; + }, []); // Reset whenever the dialog opens again — a cancelled recording must not // leak its stream or timer into the next session. @@ -80,6 +98,8 @@ export function AddAudioLayerDialog({ if (open) { setBusy(false); setRecording(false); + setPaused(false); + resumingRef.current = false; recordingRef.current = false; setElapsedSec(0); } @@ -114,11 +134,47 @@ export function AddAudioLayerDialog({ const recorder = recorderRef.current; // `recorder.onstop` (registered at start) owns the save path. if (recorder && recorder.state !== "inactive") { + freezeClock(); recorder.stop(); } for (const track of streamRef.current?.getTracks() ?? []) track.stop(); streamRef.current = null; - }, []); + }, [freezeClock]); + + const pauseRecording = useCallback(() => { + const recorder = recorderRef.current; + if (!recorder || recorder.state !== "recording" || resumingRef.current) return; + recorder.pause(); + setElapsedSec(freezeClock()); + setPaused(true); + onRecordingPause(); + }, [freezeClock, onRecordingPause]); + + const resumeRecording = useCallback(async () => { + const recorder = recorderRef.current; + if (!recorder || recorder.state !== "paused" || resumingRef.current) return; + resumingRef.current = true; + setBusy(true); + try { + // As on the first start, the picture must be playing before audio resumes. + await onRecordingResume(); + // Stop, cancel or teardown may have won while play() was pending. + if (recorderRef.current !== recorder || recorder.state !== "paused") return; + recorder.resume(); + startedAtRef.current = performance.now(); + setPaused(false); + } catch { + if (recorderRef.current === recorder && recorder.state === "paused") { + onRecordingPause(); + toast.error(t("audio.recordingUnavailable")); + } + } finally { + if (recorderRef.current === recorder) { + resumingRef.current = false; + setBusy(false); + } + } + }, [onRecordingPause, onRecordingResume, t]); const cancelRecording = useCallback(() => { discardRef.current = true; @@ -174,6 +230,7 @@ export function AddAudioLayerDialog({ ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); recorderRef.current = recorder; + resumingRef.current = false; chunksRef.current = []; recorder.ondataavailable = (event) => { if (event.data.size > 0) chunksRef.current.push(event.data); @@ -182,10 +239,11 @@ export function AddAudioLayerDialog({ const blob = new Blob(chunksRef.current, { type: recorder.mimeType || "audio/webm", }); - const duration = (performance.now() - startedAtRef.current) / 1000; + const duration = freezeClock(); const discarded = discardRef.current; discardRef.current = false; setRecording(false); + setPaused(false); recordingRef.current = false; setElapsedSec(0); onRecordingStop(); @@ -223,18 +281,20 @@ export function AddAudioLayerDialog({ return; } startedAtRef.current = performance.now(); + activeDurationMsRef.current = 0; discardRef.current = false; recorder.start(250); setRecording(true); + setPaused(false); recordingRef.current = true; setElapsedSec(0); timerRef.current = setInterval(() => { - setElapsedSec((performance.now() - startedAtRef.current) / 1000); + setElapsedSec(elapsed()); }, 200); } catch { toast.error(t("audio.micDenied")); } - }, [finishWithPath, onRecordingStart, onRecordingStop, t]); + }, [elapsed, freezeClock, finishWithPath, onRecordingStart, onRecordingStop, t]); // Stop by itself at the end of the timeline so a layer can never outlive // the video it was recorded over. @@ -291,14 +351,25 @@ export function AddAudioLayerDialog({
{t("audio.addVoiceover")} - {recording ? t("audio.recordingHint") : t("audio.subtitle")} + + {recording ? t(paused ? "audio.pausedHint" : "audio.recordingHint") : t("audio.subtitle")} + {recording ? ( <> - - {t("audio.recording")} {elapsedSec.toFixed(1)}s + + {t(paused ? "audio.paused" : "audio.recording")} {elapsedSec.toFixed(1)}s +
diff --git a/src/components/ai-edition/NewEditorShell.voiceover.test.tsx b/src/components/ai-edition/NewEditorShell.voiceover.test.tsx index 1446944d5..f0ff89ce4 100644 --- a/src/components/ai-edition/NewEditorShell.voiceover.test.tsx +++ b/src/components/ai-edition/NewEditorShell.voiceover.test.tsx @@ -357,4 +357,20 @@ describe("paused voiceover preview", () => { expect(mocks.previewPause).toHaveBeenCalledTimes(3); expect(screen.getByTestId("preview-recording-mode")).toHaveAttribute("data-unedited", "false"); }); + + it("does not restart preview when a paused take has reached the timeline end", async () => { + renderShell(); + fireEvent.click(screen.getByText("open-voiceover")); + await act(async () => { + fireEvent.click(screen.getByText("start-recording")); + }); + fireEvent.click(screen.getByText("pause-recording")); + act(() => { + useProjectStore.setState({ currentTimeSec: 10 }); + }); + await act(async () => { + fireEvent.click(screen.getByText("resume-recording")); + }); + expect(mocks.previewPlay).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/ai-edition/Preview.tsx b/src/components/ai-edition/Preview.tsx index 2d64b4428..01dbedf16 100644 --- a/src/components/ai-edition/Preview.tsx +++ b/src/components/ai-edition/Preview.tsx @@ -55,6 +55,7 @@ interface PreviewProps { onSeek: (sec: number) => void; onLoadedMetadata: (sec: number, assetId: string) => void; onVideoElement: (el: HTMLVideoElement | null) => void; + cancelPendingPlaybackRef?: React.MutableRefObject<(() => void) | null>; // ponytail: the transport bar (play/pause, prev/next, loop, scrub) moved // into the timeline header (Bottombar), so playback state now lives in // the parent shell — Preview only needs `playing` to report it on the @@ -91,6 +92,7 @@ export function Preview({ onSeek, onLoadedMetadata, onVideoElement, + cancelPendingPlaybackRef, playing, }: PreviewProps) { const te = useScopedT("editor"); @@ -235,6 +237,7 @@ export function Preview({ onSeek={onSeek} onLoadedMetadata={onLoadedMetadata} onVideoElement={onVideoElement} + cancelPendingPlaybackRef={cancelPendingPlaybackRef} currentTimeSec={currentTimeSec} onVideoError={handleVideoError} onVideoRecovered={handleVideoRecovered} diff --git a/src/components/ai-edition/PreviewCanvas.tsx b/src/components/ai-edition/PreviewCanvas.tsx index 24dca18be..e43e6c273 100644 --- a/src/components/ai-edition/PreviewCanvas.tsx +++ b/src/components/ai-edition/PreviewCanvas.tsx @@ -107,6 +107,7 @@ interface PreviewCanvasProps { onSeek: (sec: number) => void; onLoadedMetadata: (sec: number, assetId: string) => void; onVideoElement: (el: HTMLVideoElement | null) => void; + cancelPendingPlaybackRef?: React.MutableRefObject<(() => void) | null>; currentTimeSec: number; /** Receives the id of the asset whose