diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index abb688d..1b82992 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -117,6 +117,14 @@ interface Window { discarded?: boolean; error?: string; }>; + pauseNativeWindowsRecording: () => Promise<{ + success: boolean; + error?: string; + }>; + resumeNativeWindowsRecording: () => Promise<{ + success: boolean; + error?: string; + }>; startNativeMacRecording: ( request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest, ) => Promise; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 009ade6..669f1dd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -384,6 +384,10 @@ let nativeWindowsCaptureWebcamTargetPath: string | null = null; let nativeWindowsCaptureRecordingId: number | null = null; let nativeWindowsCursorOffsetMs = 0; let nativeWindowsCursorCaptureMode: CursorCaptureMode = "editable-overlay"; +let nativeWindowsCursorRecordingStartMs = 0; +let nativeWindowsPauseStartedAtMs: number | null = null; +let nativeWindowsPauseRanges: Array<{ startMs: number; endMs: number }> = []; +let nativeWindowsIsPaused = false; const NATIVE_WINDOWS_CAPTURE_STOP_TIMEOUT_MS = 15_000; let nativeMacCaptureProcess: ChildProcessWithoutNullStreams | null = null; let nativeMacCaptureOutput = ""; @@ -873,6 +877,18 @@ function completeNativeMacCursorPauseRange(endMs = Date.now()) { nativeMacPauseStartedAtMs = null; } +function completeNativeWindowsCursorPauseRange(endMs = Date.now()) { + if (nativeWindowsPauseStartedAtMs === null || nativeWindowsCursorRecordingStartMs <= 0) { + return; + } + + nativeWindowsPauseRanges.push({ + startMs: Math.max(0, nativeWindowsPauseStartedAtMs - nativeWindowsCursorRecordingStartMs), + endMs: Math.max(0, endMs - nativeWindowsCursorRecordingStartMs), + }); + nativeWindowsPauseStartedAtMs = null; +} + function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -1583,9 +1599,14 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = recordingId; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = cursorCaptureMode; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { + nativeWindowsCursorRecordingStartMs = cursorStartTimeMs; await startCursorRecording(cursorStartTimeMs); console.info("[native-wgc] cursor sampler ready", { cursorStartTimeMs, @@ -1635,6 +1656,10 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; await stopCursorRecording(); return { success: false, error: String(error) }; } @@ -1836,6 +1861,50 @@ export function registerIpcHandlers( } }); + ipcMain.handle("pause-native-windows-recording", async () => { + const proc = nativeWindowsCaptureProcess; + if (!proc) { + return { success: false, error: "Native Windows capture is not running." }; + } + if (nativeWindowsIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native Windows capture command channel is closed." }; + } + + try { + proc.stdin.write("pause\n"); + nativeWindowsIsPaused = true; + nativeWindowsPauseStartedAtMs = Date.now(); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle("resume-native-windows-recording", async () => { + const proc = nativeWindowsCaptureProcess; + if (!proc) { + return { success: false, error: "Native Windows capture is not running." }; + } + if (!nativeWindowsIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native Windows capture command channel is closed." }; + } + + try { + proc.stdin.write("resume\n"); + completeNativeWindowsCursorPauseRange(); + nativeWindowsIsPaused = false; + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + ipcMain.handle("stop-native-windows-recording", async (_, discard?: boolean) => { const proc = nativeWindowsCaptureProcess; const preferredPath = nativeWindowsCaptureTargetPath; @@ -1848,6 +1917,7 @@ export function registerIpcHandlers( } try { + completeNativeWindowsCursorPauseRange(); const stoppedPathPromise = waitForNativeWindowsCaptureStop(proc); proc.stdin.write("stop\n"); const stoppedPath = await stoppedPathPromise; @@ -1872,6 +1942,7 @@ export function registerIpcHandlers( } if (cursorCaptureMode === "editable-overlay") { + compactPendingCursorTelemetryPauseRanges(nativeWindowsPauseRanges); shiftPendingCursorTelemetry(nativeWindowsCursorOffsetMs); await writePendingCursorTelemetry(screenVideoPath); } @@ -1913,6 +1984,10 @@ export function registerIpcHandlers( nativeWindowsCaptureRecordingId = null; nativeWindowsCursorOffsetMs = 0; nativeWindowsCursorCaptureMode = "editable-overlay"; + nativeWindowsCursorRecordingStartMs = 0; + nativeWindowsPauseStartedAtMs = null; + nativeWindowsPauseRanges = []; + nativeWindowsIsPaused = false; const source = selectedSource || { name: "Screen" }; if (onRecordingStateChange) { onRecordingStateChange(false, source.name); diff --git a/electron/native/wgc-capture/src/audio_sample_utils.cpp b/electron/native/wgc-capture/src/audio_sample_utils.cpp index c816fea..6b50325 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.cpp +++ b/electron/native/wgc-capture/src/audio_sample_utils.cpp @@ -279,6 +279,7 @@ bool AudioMixer::start() { stopRequested_ = false; emittedFrames_ = 0; timelineStarted_ = false; + paused_ = false; thread_ = std::thread([this] { mixLoop(); }); @@ -296,6 +297,18 @@ void AudioMixer::beginTimeline() { cv_.notify_all(); } +void AudioMixer::setPaused(bool paused) { + { + std::scoped_lock lock(mutex_); + paused_ = paused; + if (paused_) { + systemQueue_.clear(); + microphoneQueue_.clear(); + } + } + cv_.notify_all(); +} + void AudioMixer::stop() { stopRequested_ = true; cv_.notify_all(); @@ -311,6 +324,9 @@ void AudioMixer::pushSystem(const BYTE* data, DWORD byteCount) { { std::scoped_lock lock(mutex_); + if (paused_) { + return; + } append(systemQueue_, data, byteCount, systemFormat_, 1.0); } cv_.notify_all(); @@ -323,6 +339,9 @@ void AudioMixer::pushMicrophone(const BYTE* data, DWORD byteCount) { { std::scoped_lock lock(mutex_); + if (paused_) { + return; + } append(microphoneQueue_, data, byteCount, microphoneFormat_, microphoneGain_); } cv_.notify_all(); @@ -371,13 +390,13 @@ void AudioMixer::mixLoop() { const bool hasMicrophone = !includeMicrophone_ || microphoneQueue_.size() >= chunkBytes; const bool hasAnySource = !systemQueue_.empty() || !microphoneQueue_.empty(); return stopRequested_.load() || - (timelineStarted_ && (hasSystem || hasMicrophone) && hasAnySource); + (timelineStarted_ && !paused_ && (hasSystem || hasMicrophone) && hasAnySource); }); if (stopRequested_) { break; } - if (!timelineStarted_) { + if (!timelineStarted_ || paused_) { continue; } diff --git a/electron/native/wgc-capture/src/audio_sample_utils.h b/electron/native/wgc-capture/src/audio_sample_utils.h index 4e61252..0bdbc08 100644 --- a/electron/native/wgc-capture/src/audio_sample_utils.h +++ b/electron/native/wgc-capture/src/audio_sample_utils.h @@ -52,6 +52,7 @@ public: bool start(); void beginTimeline(); + void setPaused(bool paused); void stop(); void pushSystem(const BYTE* data, DWORD byteCount); void pushMicrophone(const BYTE* data, DWORD byteCount); @@ -81,5 +82,6 @@ private: std::thread thread_; std::atomic stopRequested_ = false; bool timelineStarted_ = false; + bool paused_ = false; uint64_t emittedFrames_ = 0; }; diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 7968d94..bb741d3 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +51,37 @@ struct CaptureConfig { int webcamFps = 0; }; +struct CaptureControl { + std::atomic stopRequested = false; + std::atomic paused = false; + std::mutex mutex; + std::condition_variable cv; + std::chrono::steady_clock::time_point pauseStartedAt; + std::chrono::steady_clock::duration totalPausedDuration{}; + + int64_t pausedDurationHns() { + std::scoped_lock lock(mutex); + auto total = totalPausedDuration; + if (paused.load()) { + total += std::chrono::steady_clock::now() - pauseStartedAt; + } + return std::chrono::duration_cast(total).count() / 100; + } + + void setPaused(bool nextPaused) { + std::scoped_lock lock(mutex); + if (nextPaused == paused.load()) { + return; + } + if (nextPaused) { + pauseStartedAt = std::chrono::steady_clock::now(); + } else { + totalPausedDuration += std::chrono::steady_clock::now() - pauseStartedAt; + } + paused = nextPaused; + } +}; + std::wstring utf8ToWide(const std::string& value) { if (value.empty()) { return {}; @@ -319,17 +351,31 @@ bool parseConfig(const std::string& json, CaptureConfig& config) { return true; } -void readStopCommands(std::atomic& stopRequested, std::condition_variable& cv) { +void readCaptureCommands(CaptureControl& control, const std::function& onPauseChanged) { std::string line; while (std::getline(std::cin, line)) { if (line == "stop" || line == "q" || line == "quit") { - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } + if (line == "pause") { + control.setPaused(true); + onPauseChanged(true); + std::cout << "{\"event\":\"recording-paused\",\"schemaVersion\":2}" << std::endl; + control.cv.notify_all(); + continue; + } + if (line == "resume") { + control.setPaused(false); + onPauseChanged(false); + std::cout << "{\"event\":\"recording-resumed\",\"schemaVersion\":2}" << std::endl; + control.cv.notify_all(); + continue; + } } - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); } } // namespace @@ -489,8 +535,7 @@ int main(int argc, char* argv[]) { } std::mutex mutex; - std::condition_variable cv; - std::atomic stopRequested = false; + CaptureControl control; std::atomic firstFrameWritten = false; std::atomic encodeFailed = false; Microsoft::WRL::ComPtr latestFrameTexture; @@ -503,7 +548,7 @@ int main(int argc, char* argv[]) { bool hasVisibleWebcamFrame = false; session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { - if (stopRequested) { + if (control.stopRequested || control.paused) { return; } @@ -516,8 +561,8 @@ int main(int argc, char* argv[]) { desc.MiscFlags = 0; if (FAILED(session.device()->CreateTexture2D(&desc, nullptr, &latestFrameTexture))) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } } @@ -525,20 +570,29 @@ int main(int argc, char* argv[]) { session.context()->CopyResource(latestFrameTexture.Get(), texture); latestFrameTimestampHns = timestampHns; if (!firstFrameWritten.exchange(true)) { - cv.notify_all(); + control.cv.notify_all(); } }); auto writeVideoFrames = [&]() { - const auto startedAt = std::chrono::steady_clock::now(); + const auto frameDuration = std::chrono::duration_cast( + std::chrono::duration(1.0 / config.fps)); uint64_t frameIndex = 0; uint64_t lastWrittenWebcamSequence = 0; uint64_t webcamOutputFrameIndex = 0; int64_t lastEncodedVideoTimestampHns = -1; - while (!stopRequested && !encodeFailed) { + while (!control.stopRequested && !encodeFailed) { { - std::scoped_lock lock(mutex); + std::unique_lock lock(mutex); + control.cv.wait(lock, [&] { + return control.stopRequested.load() || + encodeFailed.load() || + (!control.paused.load() && latestFrameTexture); + }); + if (control.stopRequested || encodeFailed) { + break; + } if (webcamActive) { WebcamFrameSnapshot candidateWebcamFrame; if (webcamCapture.copyLatestFrame(candidateWebcamFrame) && @@ -564,7 +618,9 @@ int main(int argc, char* argv[]) { firstFrameTimestampHns = sourceTimestampHns; } int64_t frameTimestampHns = - std::max(0, sourceTimestampHns - firstFrameTimestampHns); + std::max( + 0, + sourceTimestampHns - firstFrameTimestampHns - control.pausedDurationHns()); if (lastEncodedVideoTimestampHns >= 0 && frameTimestampHns <= lastEncodedVideoTimestampHns) { frameTimestampHns = @@ -588,8 +644,8 @@ int main(int argc, char* argv[]) { frameTimestampHns, !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return; } if (latestFrameTexture) { @@ -598,10 +654,7 @@ int main(int argc, char* argv[]) { } frameIndex += 1; - const auto nextDeadline = startedAt + - std::chrono::duration_cast( - std::chrono::duration(static_cast(frameIndex) / config.fps)); - std::this_thread::sleep_until(nextDeadline); + std::this_thread::sleep_for(frameDuration); } }; @@ -633,8 +686,8 @@ int main(int argc, char* argv[]) { [&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) { encodeFailed = true; - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); return false; } return true; @@ -649,7 +702,7 @@ int main(int argc, char* argv[]) { if (!microphoneCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { (void)timestampHns; (void)durationHns; - if (stopRequested || !audioMixer) { + if (control.stopRequested || !audioMixer) { return; } @@ -665,7 +718,7 @@ int main(int argc, char* argv[]) { if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { (void)timestampHns; (void)durationHns; - if (stopRequested || !audioMixer) { + if (control.stopRequested || !audioMixer) { return; } @@ -726,16 +779,20 @@ int main(int argc, char* argv[]) { return 1; } - std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv)); + std::thread stdinThread(readCaptureCommands, std::ref(control), [&](bool isPaused) { + if (audioMixer) { + audioMixer->setPaused(isPaused); + } + }); { std::unique_lock lock(mutex); - const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] { - return firstFrameWritten.load() || stopRequested.load(); + const bool started = control.cv.wait_for(lock, std::chrono::seconds(10), [&] { + return firstFrameWritten.load() || control.stopRequested.load(); }); if (!started || !firstFrameWritten) { - stopRequested = true; - cv.notify_all(); + control.stopRequested = true; + control.cv.notify_all(); if (stdinThread.joinable()) { stdinThread.detach(); } @@ -761,8 +818,8 @@ int main(int argc, char* argv[]) { { std::unique_lock lock(mutex); - cv.wait(lock, [&] { - return stopRequested.load(); + control.cv.wait(lock, [&] { + return control.stopRequested.load(); }); } diff --git a/electron/preload.ts b/electron/preload.ts index 361eb18..933ce9d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -87,6 +87,12 @@ contextBridge.exposeInMainWorld("electronAPI", { stopNativeWindowsRecording: (discard?: boolean) => { return ipcRenderer.invoke("stop-native-windows-recording", discard); }, + pauseNativeWindowsRecording: () => { + return ipcRenderer.invoke("pause-native-windows-recording"); + }, + resumeNativeWindowsRecording: () => { + return ipcRenderer.invoke("resume-native-windows-recording"); + }, startNativeMacRecording: (request: NativeMacRecordingRequest) => { return ipcRenderer.invoke("start-native-mac-recording", request); }, diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 46a1f05..29df4d8 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -37,6 +37,21 @@ function findVcVarsAll() { return null; } +function findWindowsSdkUmLibDir() { + const sdkLibRoot = "C:\\Program Files (x86)\\Windows Kits\\10\\Lib"; + if (!fs.existsSync(sdkLibRoot)) { + return null; + } + + return fs + .readdirSync(sdkLibRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64")) + .filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib"))) + .sort() + .at(-1); +} + function run(command, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -64,6 +79,8 @@ async function runInVsEnv(command) { ); } + const sdkUmLibDir = findWindowsSdkUmLibDir(); + const cmdPath = path.join(os.tmpdir(), `openscreen-build-wgc-${process.pid}-${Date.now()}.cmd`); fs.writeFileSync( cmdPath, @@ -72,9 +89,9 @@ async function runInVsEnv(command) { `call "${vcvarsAll}" x64`, "if errorlevel 1 exit /b %errorlevel%", `if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`, - `for %%L in (gdi32.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, + `for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, "if errorlevel 1 exit /b %errorlevel%", - `set "LIB=${COMPAT_LIB_DIR};%LIB%"`, + `set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`, command, "exit /b %errorlevel%", "", diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 4ad11c6..14e55b3 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -82,6 +82,7 @@ type RecorderHandle = { type NativeWindowsRecordingHandle = { recordingId: number; finalizing: boolean; + paused: boolean; webcamRecorder: RecorderHandle | null; }; @@ -148,9 +149,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const webcamAcquireId = useRef(0); const canPauseRecording = recording && - !nativeWindowsRecording.current && Boolean( - (nativeMacRecording.current && !nativeMacRecording.current.finalizing) || + (nativeWindowsRecording.current && !nativeWindowsRecording.current.finalizing) || + (nativeMacRecording.current && !nativeMacRecording.current.finalizing) || (screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"), ); @@ -875,6 +876,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { nativeWindowsRecording.current = { recordingId: result.recordingId, finalizing: false, + paused: false, webcamRecorder: browserWebcamRecorder, }; webcamRecorder.current = browserWebcamRecorder; @@ -1403,6 +1405,39 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; const togglePaused = () => { + const activeNativeWindowsRecording = nativeWindowsRecording.current; + if (activeNativeWindowsRecording && !activeNativeWindowsRecording.finalizing) { + void (async () => { + try { + if (activeNativeWindowsRecording.paused) { + const result = await window.electronAPI.resumeNativeWindowsRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to resume native Windows recording"); + } + activeNativeWindowsRecording.paused = false; + segmentStartedAt.current = Date.now(); + setPaused(false); + return; + } + + const pausedAtMs = getRecordingDurationMs(); + const result = await window.electronAPI.pauseNativeWindowsRecording(); + if (!result.success) { + throw new Error(result.error ?? "Failed to pause native Windows recording"); + } + activeNativeWindowsRecording.paused = true; + accumulatedDurationMs.current = pausedAtMs; + segmentStartedAt.current = null; + setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); + setPaused(true); + } catch (error) { + console.error("Failed to toggle native Windows pause state:", error); + toast.error(error instanceof Error ? error.message : "Failed to toggle pause state"); + } + })(); + return; + } + const activeNativeMacRecording = nativeMacRecording.current; if (activeNativeMacRecording && !activeNativeMacRecording.finalizing) { void (async () => {