diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 82b85145..44b894c2 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -130,7 +130,6 @@ export default function Item({ }} onClick={(event) => { event.stopPropagation(); - onSelect?.(); }} >
{ it("holds the next region's focus between connected-transition end and next start", () => { const regions: ZoomRegion[] = [ { id: "a", startMs: 1000, endMs: 3000, depth: 2, focus: { cx: 0.2, cy: 0.2 } }, - { id: "b", startMs: 4200, endMs: 7000, depth: 3, focus: { cx: 0.7, cy: 0.7 } }, + { id: "b", startMs: 4300, endMs: 7000, depth: 3, focus: { cx: 0.7, cy: 0.7 } }, ]; - // After transition end (3000+200+1000=4200) but before b starts (4200) - const result = findDominantRegion(regions, 4100, { connectZooms: true }); + // After transition end (3000+200+1000=4200) but before b starts (4300) + const result = findDominantRegion(regions, 4250, { connectZooms: true }); expect(result.strength).toBe(1); expect(result.region).not.toBeNull(); expect(result.region!.id).toBe("b"); diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 11a412ed..fc49e046 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -157,7 +157,7 @@ function getActiveRegion( function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) { for (const pair of connectedPairs) { - if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) { + if (timeMs >= pair.transitionEnd && timeMs < pair.nextRegion.startMs) { const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth]; return { region: { diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 33f9cb39..5070bd11 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -398,38 +398,101 @@ export function useScreenRecorder(): UseScreenRecorderReturn { [clearRecordingFinalizationToast], ); + const stopMicFallbackRecorder = useCallback((): Promise => { + return new Promise((resolve) => { + const recorder = micFallbackRecorder.current; + if (!recorder || recorder.state === "inactive") { + micFallbackRecorder.current = null; + resolve(null); + return; + } + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + micFallbackChunks.current.push(event.data); + } + }; + recorder.onstop = () => { + const blob = + micFallbackChunks.current.length > 0 + ? new Blob(micFallbackChunks.current, { type: recorder.mimeType }) + : null; + micFallbackChunks.current = []; + recorder.stream.getTracks().forEach((track) => track.stop()); + micFallbackRecorder.current = null; + resolve(blob); + }; + recorder.stop(); + }); + }, []); + + const storeMicrophoneSidecar = useCallback( + async (micFallbackBlobPromise: Promise | null | undefined, finalPath: string) => { + const micFallbackBlob = await micFallbackBlobPromise; + if (!micFallbackBlob) { + return; + } + + try { + const arrayBuffer = await micFallbackBlob.arrayBuffer(); + await window.electronAPI.storeMicrophoneSidecar(arrayBuffer, finalPath); + } catch (error) { + console.warn("Failed to store microphone sidecar:", error); + } + }, + [], + ); + const stopWebcamRecorder = useCallback(async () => { const recorder = webcamRecorder.current; const pending = webcamStopPromise.current; if (!recorder) { - return null; + const result = pending ? await pending : resolvedWebcamPath.current; + webcamStopPromise.current = null; + pendingWebcamPathPromise.current = null; + resolvedWebcamPath.current = result ?? null; + return result ?? null; } if (recorder.state !== "inactive") { recorder.stop(); + } else if (pending && webcamStopResolver.current) { + webcamStopResolver.current(resolvedWebcamPath.current); + webcamStopResolver.current = null; } - const result = pending ? await pending : null; - resolvedWebcamPath.current = result; + const result = pending ? await pending : resolvedWebcamPath.current; + webcamStopPromise.current = null; pendingWebcamPathPromise.current = null; - return result; + resolvedWebcamPath.current = result ?? null; + return result ?? null; }, []); - const recoverNativeRecordingSession = useCallback(async () => { - if (typeof window.electronAPI?.recoverNativeScreenRecording !== "function") { - return null; - } + const recoverNativeRecordingSession = useCallback( + async (micFallbackBlobPromise?: Promise | null) => { + if (typeof window.electronAPI?.recoverNativeScreenRecording !== "function") { + return null; + } - const result = await window.electronAPI.recoverNativeScreenRecording(); - if (!result.success || !result.path) { - return null; - } + const result = await window.electronAPI.recoverNativeScreenRecording(); + if (!result.success || !result.path) { + return null; + } - const webcamPath = await stopWebcamRecorder(); - await finalizeRecordingSession(result.path, webcamPath); - return result.path; - }, [finalizeRecordingSession, stopWebcamRecorder]); + const resolvedMicFallbackBlobPromise = + micFallbackBlobPromise ?? stopMicFallbackRecorder(); + const webcamPath = await stopWebcamRecorder(); + await storeMicrophoneSidecar(resolvedMicFallbackBlobPromise, result.path); + await finalizeRecordingSession(result.path, webcamPath); + return result.path; + }, + [ + finalizeRecordingSession, + stopMicFallbackRecorder, + stopWebcamRecorder, + storeMicrophoneSidecar, + ], + ); /** * Acquire the webcam stream and prepare the MediaRecorder, but do NOT start @@ -573,7 +636,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); void logNativeCaptureDiagnostics("stop-native-screen-recording"); try { - const recoveredPath = await recoverNativeRecordingSession(); + const recoveredPath = await recoverNativeRecordingSession( + micFallbackBlobPromise, + ); if (recoveredPath) { return; } @@ -602,16 +667,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalPath = muxResult?.path ?? result.path; } - // Save the browser-captured microphone sidecar if one was recorded. - const micFallbackBlob = await micFallbackBlobPromise; - if (micFallbackBlob && finalPath) { - try { - const arrayBuffer = await micFallbackBlob.arrayBuffer(); - await window.electronAPI.storeMicrophoneSidecar(arrayBuffer, finalPath); - } catch (e) { - console.warn("Failed to store microphone sidecar:", e); - } - } + await storeMicrophoneSidecar(micFallbackBlobPromise, finalPath); await finalizeRecordingSession(finalPath, webcamPath); })(); @@ -751,40 +807,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn { void window.electronAPI.stopNativeScreenRecording(); } - if (mediaRecorder.current?.state === "recording") { - mediaRecorder.current.stop(); + const recorder = mediaRecorder.current; + const recorderState = recorder?.state; + if (recorder && (recorderState === "recording" || recorderState === "paused")) { + recorder.stop(); } cleanupCapturedMedia(); }; }, [cleanupCapturedMedia, recoverNativeRecordingSession]); - const stopMicFallbackRecorder = useCallback((): Promise => { - return new Promise((resolve) => { - const recorder = micFallbackRecorder.current; - if (!recorder || recorder.state === "inactive") { - micFallbackRecorder.current = null; - resolve(null); - return; - } - recorder.ondataavailable = (event) => { - if (event.data.size > 0) { - micFallbackChunks.current.push(event.data); - } - }; - recorder.onstop = () => { - const blob = - micFallbackChunks.current.length > 0 - ? new Blob(micFallbackChunks.current, { type: recorder.mimeType }) - : null; - micFallbackChunks.current = []; - micFallbackRecorder.current = null; - resolve(blob); - }; - recorder.stop(); - }); - }, []); - const startRecording = async () => { if (startInFlight.current) { return;