diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 2a417df39..df679396d 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -223,6 +223,11 @@ export function NewEditorShell() { const [seekTarget, setSeekTarget] = useState(null); const [videoElement, setVideoElement] = useState(null); + const voiceoverPausedRef = useRef(false); + const voiceoverSeekEpochRef = useRef(0); + const voiceoverActiveRef = useRef(false); + const voiceoverSessionRef = useRef(0); + const cancelPendingPlaybackRef = useRef<(() => void) | null>(null); // 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"); @@ -540,8 +545,12 @@ export function NewEditorShell() { [setSourceDuration, enqueueTimelineWrite], ); + const canSeek = useCallback(() => !voiceoverPausedRef.current, []); + /** Seek the preview unless a paused take owns its current picture. */ const handleSeek = useCallback( (timeSec: number, preservePlayback?: boolean) => { + // A paused take must resume against the same picture, not a scrubbed-to position. + if (!canSeek()) return; setCurrentTime(timeSec); setSeekTarget({ timeSec, @@ -550,7 +559,7 @@ export function NewEditorShell() { preservePlayback, }); }, - [setCurrentTime], + [setCurrentTime, canSeek], ); const handleTimeChange = useCallback( @@ -644,7 +653,10 @@ export function NewEditorShell() { // here doesn't cause this effect to re-subscribe on every playhead tick. }, [videoElement, setPlaying]); + /** Toggle ordinary playback; paused takes resume through the recording bar. */ 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; @@ -1057,7 +1069,11 @@ export function NewEditorShell() { // where it is passed to the preview. const [voiceoverRecording, setVoiceoverRecording] = useState(false); + /** Anchor a new take at the playhead and start preview playback at normal speed. */ 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; @@ -1073,11 +1089,45 @@ export function NewEditorShell() { if (videoElement.paused) await videoElement.play(); }, [videoElement]); + /** End the take, invalidate pending playback and restore timeline audio. */ const handleVoiceoverRecordingStop = useCallback(() => { + voiceoverSessionRef.current++; + voiceoverActiveRef.current = false; + voiceoverPausedRef.current = false; setVoiceoverRecording(false); + cancelPendingPlaybackRef.current?.(); videoElement?.pause(); }, [videoElement]); + /** Freeze the picture and invalidate queued scrubs without ending the take. */ + const handleVoiceoverRecordingPause = useCallback(() => { + voiceoverPausedRef.current = true; + voiceoverSeekEpochRef.current++; + cancelPendingPlaybackRef.current?.(); + videoElement?.pause(); + // Keep voiceoverRecording true: the take still owns the unedited clock and mute. + }, [videoElement]); + + /** Resume the picture before the microphone, unless Stop superseded this request. */ + const handleVoiceoverRecordingResume = useCallback(async () => { + if (!voiceoverActiveRef.current) return; + if (!videoElement) throw new Error("The preview is not ready."); + const doc = useProjectStore.getState().document; + const end = + doc?.timeline.clips.reduce((max, clip) => Math.max(max, clip.timelineEndSec), 0) ?? 0; + if (end > 0 && useProjectStore.getState().currentTimeSec >= end - 0.001) return false; + 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; + return true; + }, [videoElement]); + + /** Place saved audio at the original take anchor using active recording duration. */ const handleVoiceoverReady = useCallback( async (assetId: string, durationSec: number) => { setVoiceoverFlow(null); @@ -1757,6 +1807,7 @@ export function NewEditorShell() { onSeek={handleSeek} onLoadedMetadata={handleLoadedMetadata} onVideoElement={setVideoElement} + cancelPendingPlaybackRef={cancelPendingPlaybackRef} playing={playing} /> @@ -1809,6 +1860,8 @@ export function NewEditorShell() { onAnnotationRowCountChange={setAnnotationRowCount} tl={tl} setCurrentTime={handleSeek} + canSeek={canSeek} + seekEpochRef={voiceoverSeekEpochRef} variant={mode === "media" ? "media" : "edit"} onDropAsset={handleDropAsset} videoSources={videoSources} @@ -1881,7 +1934,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); + if (voiceoverActiveRef.current) handleVoiceoverRecordingStop(); setVoiceoverFlow(null); }} onComplete={(assetId, durationSec) => { @@ -1889,6 +1942,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..f0ff89ce4 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,21 +67,41 @@ 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", () => ({ AddAudioLayerDialog: ({ open, + onClose, onComplete, onRecordingStart, onRecordingStop, + onRecordingPause, + onRecordingResume, }: { open: boolean; + onClose: () => void; onComplete: (assetId: string, durationSec: number) => void; onRecordingStart: () => void | Promise; onRecordingStop: () => void; + onRecordingPause: () => void; + onRecordingResume: () => void | Promise; }) => open ? createElement( @@ -88,6 +109,13 @@ 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: onClose }, "close-voiceover"), + 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 +183,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 +279,98 @@ describe("NewEditorShell voiceover placement", () => { ]); }); }); + +describe("paused voiceover preview", () => { + beforeEach(prepareShell); + afterEach(() => { + cleanup(); + useProjectStore.getState().clear(); + }); + + it.each([ + false, + true, + ])("only stops playback on closing an active take (active=%s)", async (active) => { + renderShell(); + await act(async () => { + fireEvent.click(screen.getByText("toggle-preview")); + }); + fireEvent.click(screen.getByText("open-voiceover")); + if (active) { + await act(async () => { + fireEvent.click(screen.getByText("start-recording")); + }); + } + fireEvent.click(screen.getByText("close-voiceover")); + expect(mocks.previewPause).toHaveBeenCalledTimes(active ? 1 : 0); + expect(screen.queryByText("close-voiceover")).not.toBeInTheDocument(); + }); + + 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"); + }); + + 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