diff --git a/electron/ipc/recording/windows.test.ts b/electron/ipc/recording/windows.test.ts new file mode 100644 index 000000000..73a1e0c24 --- /dev/null +++ b/electron/ipc/recording/windows.test.ts @@ -0,0 +1,102 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setWindowsCaptureOutputBuffer, setWindowsCaptureTargetPath } from "../state"; +import { waitForWindowsCaptureStop } from "./windows"; + +vi.mock("electron", () => ({ + app: { + getPath: () => "C:\\RecordlyTest", + }, + BrowserWindow: { + getAllWindows: () => [], + }, +})); + +class FakeCaptureProcess extends EventEmitter { + stdout = new PassThrough(); + stderr = new PassThrough(); + stdin = new PassThrough(); + killed = false; + + kill = vi.fn(() => { + this.killed = true; + return true; + }); +} + +describe("waitForWindowsCaptureStop", () => { + beforeEach(() => { + setWindowsCaptureOutputBuffer(""); + setWindowsCaptureTargetPath(null); + }); + + it("resolves the helper output path when the process closes cleanly", async () => { + const proc = new FakeCaptureProcess(); + setWindowsCaptureOutputBuffer("Recording stopped. Output path: C:\\Recordly\\capture.mp4"); + + const stopped = waitForWindowsCaptureStop( + proc as unknown as Parameters[0], + 1000, + ); + proc.emit("close", 0); + + await expect(stopped).resolves.toBe("C:\\Recordly\\capture.mp4"); + expect(proc.kill).not.toHaveBeenCalled(); + }); + + it("resolves the fallback target path when the helper closes cleanly without output path", async () => { + const proc = new FakeCaptureProcess(); + setWindowsCaptureOutputBuffer("Recording stopped without output path"); + setWindowsCaptureTargetPath("C:\\Recordly\\fallback.mp4"); + + const stopped = waitForWindowsCaptureStop( + proc as unknown as Parameters[0], + 1000, + ); + proc.emit("close", 0); + + await expect(stopped).resolves.toBe("C:\\Recordly\\fallback.mp4"); + expect(proc.kill).not.toHaveBeenCalled(); + }); + + it("rejects with helper output when the helper exits with a non-zero code", async () => { + const proc = new FakeCaptureProcess(); + setWindowsCaptureOutputBuffer("Encoder error: insufficient memory"); + + const stopped = waitForWindowsCaptureStop( + proc as unknown as Parameters[0], + 1000, + ); + proc.emit("close", 1); + + await expect(stopped).rejects.toThrow("Encoder error: insufficient memory"); + expect(proc.kill).not.toHaveBeenCalled(); + }); + + it("rejects when the helper emits an error", async () => { + const proc = new FakeCaptureProcess(); + const error = new Error("spawn failed"); + + const stopped = waitForWindowsCaptureStop( + proc as unknown as Parameters[0], + 1000, + ); + proc.emit("error", error); + + await expect(stopped).rejects.toBe(error); + expect(proc.kill).not.toHaveBeenCalled(); + }); + + it("kills the helper and rejects when stop never completes", async () => { + const proc = new FakeCaptureProcess(); + + await expect( + waitForWindowsCaptureStop( + proc as unknown as Parameters[0], + 5, + ), + ).rejects.toThrow("Timed out waiting for native Windows capture to stop"); + expect(proc.kill).toHaveBeenCalledTimes(1); + }); +}); diff --git a/electron/ipc/recording/windows.ts b/electron/ipc/recording/windows.ts index f3e43db59..262d26daa 100644 --- a/electron/ipc/recording/windows.ts +++ b/electron/ipc/recording/windows.ts @@ -16,8 +16,10 @@ import { import { AudioSyncAdjustment, } from "../types"; -import { emitRecordingInterrupted } from "./events"; import { moveFileWithOverwrite } from "../utils"; +import { emitRecordingInterrupted } from "./events"; + +const WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 45_000; export type NativeWindowsVideoPaddingResult = { padded: boolean; @@ -107,33 +109,58 @@ export function waitForWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) }); } -export function waitForWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) { +export function waitForWindowsCaptureStop( + proc: ChildProcessWithoutNullStreams, + timeoutMs = WINDOWS_CAPTURE_STOP_TIMEOUT_MS, +) { return new Promise((resolve, reject) => { - const onClose = (code: number | null) => { + let settled = false; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; cleanup(); - const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/); - if (match?.[1]) { - resolve(match[1].trim()); - return; - } - if (code === 0 && windowsCaptureTargetPath) { - resolve(windowsCaptureTargetPath); - return; - } - reject( - new Error( - windowsCaptureOutputBuffer.trim() || - `Native Windows capture exited with code ${code ?? "unknown"}`, - ), - ); + callback(); + }; + + const timer = setTimeout(() => { + finish(() => { + try { + if (!proc.killed) proc.kill(); + } catch { + // The process may already be gone; the caller only needs the timeout error. + } + reject(new Error("Timed out waiting for native Windows capture to stop")); + }); + }, timeoutMs); + + const onClose = (code: number | null) => { + finish(() => { + const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/); + if (match?.[1]) { + resolve(match[1].trim()); + return; + } + if (code === 0 && windowsCaptureTargetPath) { + resolve(windowsCaptureTargetPath); + return; + } + reject( + new Error( + windowsCaptureOutputBuffer.trim() || + `Native Windows capture exited with code ${code ?? "unknown"}`, + ), + ); + }); }; const onError = (error: Error) => { - cleanup(); - reject(error); + finish(() => { + reject(error); + }); }; const cleanup = () => { + clearTimeout(timer); proc.off("close", onClose); proc.off("error", onError); }; diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index cf1ec1b69..2df0f4e5b 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -5,10 +5,10 @@ "helpers": { "wgc-capture": { "binaryName": "wgc-capture.exe", - "binarySha256": "47a67765a0b7fdd9936e41a2f7e62f6408bd426774f7854e6378194ebe3ae827", + "binarySha256": "298b41f371c3881046061048b466e12ed70dd93fa761bade2fd57d1ccddf3cb9", "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "696c9f56da75105941063b7940eb91c231e781e9a3a4ba885e47bcfdf798f13f", - "updatedAt": "2026-05-22T10:43:18.337Z" + "sourceFingerprint": "6ee457080c27dc939ff4b61965f86b6d73995e40200440a1dc44865f9708d39f", + "updatedAt": "2026-05-24T19:49:15.077Z" }, "cursor-monitor": { "binaryName": "cursor-monitor.exe", diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe index 032169fa0..8180edd00 100644 Binary files a/electron/native/bin/win32-x64/wgc-capture.exe and b/electron/native/bin/win32-x64/wgc-capture.exe differ diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 4dfbb2f81..4875d0956 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -15,6 +16,25 @@ static int clampByte(int v) { return v < 0 ? 0 : (v > 255 ? 255 : v); } +static UINT32 calculateScreenRecordingBitrate(int width, int height, int fps) { + constexpr uint64_t kFourKPixels = 3840ULL * 2160ULL; + constexpr uint64_t kQhdPixels = 2560ULL * 1440ULL; + constexpr UINT32 kBitrate4K = 45000000; + constexpr UINT32 kBitrateQhd = 28000000; + constexpr UINT32 kBitrateBase = 18000000; + constexpr double kHighFrameRateBoost = 1.35; + + const uint64_t pixels = + static_cast((std::max)(width, 1)) * + static_cast((std::max)(height, 1)); + const UINT32 baseBitrate = + pixels >= kFourKPixels ? kBitrate4K : + pixels >= kQhdPixels ? kBitrateQhd : + kBitrateBase; + const double boost = fps >= 60 ? kHighFrameRateBoost : 1.0; + return static_cast(static_cast(baseBitrate) * boost + 0.5); +} + MFEncoder::MFEncoder() {} MFEncoder::~MFEncoder() { @@ -56,11 +76,14 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video); outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264); - outputType->SetUINT32(MF_MT_AVG_BITRATE, 20000000); + const UINT32 videoBitrate = calculateScreenRecordingBitrate(width_, height_, fps_); + outputType->SetUINT32(MF_MT_AVG_BITRATE, videoBitrate); MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, width_, height_); MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1); MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1); outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive); + std::cerr << "Encoder bitrate: " << videoBitrate << " bps for " + << width_ << "x" << height_ << "@" << fps_ << "fps" << std::endl; // Input media type (NV12) ComPtr inputType; diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 9e80a8549..40d40dd96 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2064,6 +2064,35 @@ export default function VideoEditor() { [currentProjectPath, webcam.timeOffsetMs], ); + const resetSourceScopedEditorState = useCallback(() => { + setZoomRegions([]); + setTrimRegions([]); + setClipRegions([]); + clipInitializedRef.current = false; + autoFullTrackClipIdRef.current = null; + autoFullTrackClipEndMsRef.current = null; + setSpeedRegions([]); + setAnnotationRegions([]); + setAudioRegions([]); + setSourceAudioTrackSettingsByClip({}); + setDefaultSourceAudioTrackSettings({}); + setHasClipSourceAudio(false); + setAutoCaptions([]); + setAutoCaptionSettings((prev) => ({ ...prev, enabled: false })); + setSelectedZoomId(null); + setSelectedClipId(null); + setSelectedAnnotationId(null); + setSelectedAudioId(null); + nextZoomIdRef.current = 1; + nextClipIdRef.current = 1; + nextAudioIdRef.current = 1; + nextAnnotationIdRef.current = 1; + nextAnnotationZIndexRef.current = 1; + resetEditorHistoryStack(editorHistoryRef.current); + applyingHistoryRef.current = false; + syncHistoryButtons(); + }, [syncHistoryButtons]); + const handleUploadWebcam = useCallback(async () => { const result = await window.electronAPI.openVideoFilePicker(); if (!result.success || !result.path) { @@ -2152,6 +2181,7 @@ export default function VideoEditor() { setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); setLastSavedSnapshot(null); + resetSourceScopedEditorState(); pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; @@ -2180,6 +2210,7 @@ export default function VideoEditor() { setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); setLastSavedSnapshot(null); + resetSourceScopedEditorState(); pendingFreshRecordingAutoZoomPathRef.current = null; setWebcam((prev) => ({ ...prev, @@ -2237,6 +2268,7 @@ export default function VideoEditor() { setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); setLastSavedSnapshot(null); + resetSourceScopedEditorState(); pendingFreshRecordingAutoZoomPathRef.current = autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; @@ -2259,6 +2291,7 @@ export default function VideoEditor() { setVideoPath(sourceVideoUrl); setCurrentProjectPath(null); setLastSavedSnapshot(null); + resetSourceScopedEditorState(); pendingFreshRecordingAutoZoomPathRef.current = null; applySessionPresentation(null); setWebcam((prev) => ({ @@ -2285,6 +2318,7 @@ export default function VideoEditor() { devOpenRecordingConfig.inputPath, devOpenRecordingConfig.webcamInputPath, initialEditorPreferences, + resetSourceScopedEditorState, smokeExportConfig.enabled, smokeExportConfig.inputPath, smokeExportConfig.projectPath, diff --git a/src/components/video-editor/audio/useSourceAudioFallback.ts b/src/components/video-editor/audio/useSourceAudioFallback.ts index 2cfbbf752..bdaf05408 100644 --- a/src/components/video-editor/audio/useSourceAudioFallback.ts +++ b/src/components/video-editor/audio/useSourceAudioFallback.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { SOURCE_AUDIO_FALLBACK_TOAST_ID } from "@/components/video-editor/audio/audioTypes"; @@ -16,13 +16,18 @@ export function useSourceAudioFallback({ const [sourceAudioFallbackPaths, setSourceAudioFallbackPaths] = useState([]); const [sourceAudioFallbackStartDelayMsByPath, setSourceAudioFallbackStartDelayMsByPath] = useState>({}); + const previousSourcePathRef = useRef(null); useEffect(() => { let cancelled = false; // Refetch when late recording sidecars are finalized after the editor opens. void refreshKey; - setSourceAudioFallbackPaths([]); - setSourceAudioFallbackStartDelayMsByPath({}); + const sourceChanged = previousSourcePathRef.current !== currentSourcePath; + previousSourcePathRef.current = currentSourcePath; + if (sourceChanged) { + setSourceAudioFallbackPaths([]); + setSourceAudioFallbackStartDelayMsByPath({}); + } if (!currentSourcePath) { return () => { @@ -37,8 +42,10 @@ export function useSourceAudioFallback({ return; } if (!result.success) { - setSourceAudioFallbackPaths([]); - setSourceAudioFallbackStartDelayMsByPath({}); + if (sourceChanged) { + setSourceAudioFallbackPaths([]); + setSourceAudioFallbackStartDelayMsByPath({}); + } toast.warning( result.error ? `Could not load companion audio sources: ${summarizeErrorMessage(result.error)}` @@ -53,8 +60,10 @@ export function useSourceAudioFallback({ setSourceAudioFallbackStartDelayMsByPath(result.startDelayMsByPath ?? {}); } catch (error) { if (!cancelled) { - setSourceAudioFallbackPaths([]); - setSourceAudioFallbackStartDelayMsByPath({}); + if (sourceChanged) { + setSourceAudioFallbackPaths([]); + setSourceAudioFallbackStartDelayMsByPath({}); + } toast.warning( `Could not load companion audio sources: ${summarizeErrorMessage(String(error))}`, { id: SOURCE_AUDIO_FALLBACK_TOAST_ID, duration: 10000 },