diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 3530dcb..3c2551e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -120,6 +120,14 @@ interface Window { startNativeMacRecording: ( request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest, ) => Promise; + pauseNativeMacRecording: () => Promise<{ + success: boolean; + error?: string; + }>; + resumeNativeMacRecording: () => Promise<{ + success: boolean; + error?: string; + }>; stopNativeMacRecording: (discard?: boolean) => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index c653661..e7522c7 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -292,6 +292,10 @@ let nativeMacCaptureTargetPath: string | null = null; let nativeMacCaptureRecordingId: number | null = null; let nativeMacCursorOffsetMs = 0; let nativeMacCursorCaptureMode: CursorCaptureMode = "editable-overlay"; +let nativeMacCursorRecordingStartMs = 0; +let nativeMacPauseStartedAtMs: number | null = null; +let nativeMacPauseRanges: Array<{ startMs: number; endMs: number }> = []; +let nativeMacIsPaused = false; function normalizeCursorSample(sample: unknown): CursorRecordingSample | null { if (!sample || typeof sample !== "object") { @@ -714,6 +718,62 @@ function shiftPendingCursorTelemetry(offsetMs: number) { }; } +function compactPendingCursorTelemetryPauseRanges( + ranges: Array<{ startMs: number; endMs: number }>, +) { + if (!pendingCursorRecordingData || ranges.length === 0) { + return; + } + + const normalizedRanges = ranges + .map((range) => ({ + startMs: Math.max(0, Math.min(range.startMs, range.endMs)), + endMs: Math.max(0, Math.max(range.startMs, range.endMs)), + })) + .filter((range) => Number.isFinite(range.startMs) && Number.isFinite(range.endMs)) + .filter((range) => range.endMs > range.startMs) + .sort((a, b) => a.startMs - b.startMs); + + if (normalizedRanges.length === 0) { + return; + } + + pendingCursorRecordingData = { + ...pendingCursorRecordingData, + samples: pendingCursorRecordingData.samples + .map((sample) => { + let pausedBeforeSampleMs = 0; + for (const range of normalizedRanges) { + if (sample.timeMs >= range.startMs && sample.timeMs <= range.endMs) { + return null; + } + if (sample.timeMs > range.endMs) { + pausedBeforeSampleMs += range.endMs - range.startMs; + } + } + + return { + ...sample, + timeMs: Math.max(0, sample.timeMs - pausedBeforeSampleMs), + }; + }) + .filter((sample): sample is CursorRecordingSample => Boolean(sample)) + .sort((a, b) => a.timeMs - b.timeMs), + }; +} + +function completeNativeMacCursorPauseRange(endMs = Date.now()) { + if (nativeMacPauseStartedAtMs === null || nativeMacCursorRecordingStartMs <= 0) { + return; + } + + nativeMacPauseRanges.push({ + startMs: Math.max(0, nativeMacPauseStartedAtMs - nativeMacCursorRecordingStartMs), + endMs: Math.max(0, endMs - nativeMacCursorRecordingStartMs), + }); + nativeMacPauseStartedAtMs = null; +} + function waitForNativeWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -1537,9 +1597,14 @@ export function registerIpcHandlers( nativeMacCaptureRecordingId = recordingId; nativeMacCursorOffsetMs = 0; nativeMacCursorCaptureMode = cursorCaptureMode; + nativeMacCursorRecordingStartMs = 0; + nativeMacPauseStartedAtMs = null; + nativeMacPauseRanges = []; + nativeMacIsPaused = false; const cursorStartTimeMs = Date.now(); if (cursorCaptureMode === "editable-overlay") { + nativeMacCursorRecordingStartMs = cursorStartTimeMs; await startCursorRecording(cursorStartTimeMs); } else { pendingCursorRecordingData = null; @@ -1577,11 +1642,59 @@ export function registerIpcHandlers( nativeMacCaptureRecordingId = null; nativeMacCursorOffsetMs = 0; nativeMacCursorCaptureMode = "editable-overlay"; + nativeMacCursorRecordingStartMs = 0; + nativeMacPauseStartedAtMs = null; + nativeMacPauseRanges = []; + nativeMacIsPaused = false; await stopCursorRecording(); return { success: false, error: error instanceof Error ? error.message : String(error) }; } }); + ipcMain.handle("pause-native-mac-recording", async () => { + const proc = nativeMacCaptureProcess; + if (!proc) { + return { success: false, error: "Native macOS capture is not running." }; + } + if (nativeMacIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native macOS capture command channel is closed." }; + } + + try { + proc.stdin.write("pause\n"); + nativeMacIsPaused = true; + nativeMacPauseStartedAtMs = Date.now(); + return { success: true }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); + + ipcMain.handle("resume-native-mac-recording", async () => { + const proc = nativeMacCaptureProcess; + if (!proc) { + return { success: false, error: "Native macOS capture is not running." }; + } + if (!nativeMacIsPaused) { + return { success: true }; + } + if (!proc.stdin.writable) { + return { success: false, error: "Native macOS capture command channel is closed." }; + } + + try { + proc.stdin.write("resume\n"); + completeNativeMacCursorPauseRange(); + nativeMacIsPaused = 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; @@ -1677,6 +1790,7 @@ export function registerIpcHandlers( } try { + completeNativeMacCursorPauseRange(); const stoppedPathPromise = waitForNativeMacCaptureStop(proc); proc.stdin.write("stop\n"); const stoppedPath = await stoppedPathPromise; @@ -1700,6 +1814,7 @@ export function registerIpcHandlers( } if (cursorCaptureMode === "editable-overlay") { + compactPendingCursorTelemetryPauseRanges(nativeMacPauseRanges); shiftPendingCursorTelemetry(nativeMacCursorOffsetMs); await writePendingCursorTelemetry(screenVideoPath); } @@ -1734,6 +1849,10 @@ export function registerIpcHandlers( nativeMacCaptureRecordingId = null; nativeMacCursorOffsetMs = 0; nativeMacCursorCaptureMode = "editable-overlay"; + nativeMacCursorRecordingStartMs = 0; + nativeMacPauseStartedAtMs = null; + nativeMacPauseRanges = []; + nativeMacIsPaused = false; const source = selectedSource || { name: "Screen" }; if (onRecordingStateChange) { onRecordingStateChange(false, source.name); diff --git a/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift index 7afebc8..830f692 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift @@ -123,7 +123,6 @@ func actionNames(_ element: AXUIElement) -> [String] { return (value as NSArray).compactMap { $0 as? String } } - func isTextInputRole(_ role: String?) -> Bool { role == "AXTextField" || role == "AXTextArea" || @@ -147,8 +146,7 @@ func isPointerRole(_ role: String?, _ subrole: String?, _ description: String?) role == "AXSwitch" || role == "AXDisclosureTriangle" || role == "AXTab" || - role == "AXMenuItem" || - role == "AXCell" + role == "AXMenuItem" } func cursorTypeForElement(_ element: AXUIElement) -> String? { @@ -167,7 +165,7 @@ func cursorTypeForElement(_ element: AXUIElement) -> String? { return "text" } - if isPointerRole(role, subrole, description) || actionNames(element).contains(kAXPressAction) { + if isPointerRole(role, subrole, description) { return "pointer" } diff --git a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift index af59157..6b71d75 100644 --- a/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift +++ b/electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift @@ -137,10 +137,14 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { private var didStartWriting = false private var didEmitRecordingStarted = false private var isStopping = false + private var isPaused = false + private var pauseStartedAt: CMTime? + private var totalPausedDuration = CMTime.zero private var nativeMicrophoneEnabled = false private var outputWidth = 1920 private var outputHeight = 1080 private let microphoneOutputTypeRawValue = 2 + private let hostClock = CMClockGetHostTimeClock() init(request: RecordingRequest) { self.request = request @@ -203,6 +207,51 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { await finishWriter() } + func pause() { + let didPause = stateQueue.sync { + if isStopping || isPaused { + return false + } + + isPaused = true + pauseStartedAt = CMClockGetTime(hostClock) + return true + } + + if didPause { + emit([ + "event": "recording-paused", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + ]) + } + } + + func resume() { + let didResume = stateQueue.sync { + if isStopping || !isPaused { + return false + } + + if let pauseStartedAt { + let now = CMClockGetTime(hostClock) + totalPausedDuration = CMTimeAdd( + totalPausedDuration, + CMTimeSubtract(now, pauseStartedAt) + ) + } + isPaused = false + pauseStartedAt = nil + return true + } + + if didResume { + emit([ + "event": "recording-resumed", + "timestampMs": Int(Date().timeIntervalSince1970 * 1000), + ]) + } + } + func stream(_ stream: SCStream, didStopWithError error: Error) { emitError(code: "capture-stopped-with-error", message: "\(error)") Task { @@ -214,6 +263,13 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { guard CMSampleBufferDataIsReady(sampleBuffer) else { return } + let pauseState = currentPauseState() + if pauseState.paused { + return + } + guard let sampleBuffer = retimedSampleBuffer(sampleBuffer, subtracting: pauseState.offset) else { + return + } if type == .audio { appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput) @@ -450,6 +506,70 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate { input.append(sampleBuffer) } + private func currentPauseState() -> (paused: Bool, offset: CMTime) { + stateQueue.sync { + (isPaused, totalPausedDuration) + } + } + + private func retimedSampleBuffer(_ sampleBuffer: CMSampleBuffer, subtracting offset: CMTime) -> CMSampleBuffer? { + if !offset.isValid || offset == .zero { + return sampleBuffer + } + + let sampleCount = CMSampleBufferGetNumSamples(sampleBuffer) + if sampleCount <= 0 { + return sampleBuffer + } + + var timing = Array(repeating: CMSampleTimingInfo(), count: sampleCount) + let timingStatus = CMSampleBufferGetSampleTimingInfoArray( + sampleBuffer, + entryCount: sampleCount, + arrayToFill: &timing, + entriesNeededOut: nil + ) + if timingStatus != noErr { + emit([ + "event": "warning", + "code": "sample-retime-failed", + "message": "Unable to read sample timing info: \(timingStatus).", + ]) + return sampleBuffer + } + + for index in timing.indices { + if timing[index].presentationTimeStamp.isValid { + timing[index].presentationTimeStamp = CMTimeSubtract( + timing[index].presentationTimeStamp, + offset + ) + } + if timing[index].decodeTimeStamp.isValid { + timing[index].decodeTimeStamp = CMTimeSubtract(timing[index].decodeTimeStamp, offset) + } + } + + var retimedBuffer: CMSampleBuffer? + let copyStatus = CMSampleBufferCreateCopyWithNewTiming( + allocator: kCFAllocatorDefault, + sampleBuffer: sampleBuffer, + sampleTimingEntryCount: sampleCount, + sampleTimingArray: &timing, + sampleBufferOut: &retimedBuffer + ) + if copyStatus != noErr { + emit([ + "event": "warning", + "code": "sample-retime-failed", + "message": "Unable to copy sample timing info: \(copyStatus).", + ]) + return sampleBuffer + } + + return retimedBuffer + } + private func isCompleteFrame(_ sampleBuffer: CMSampleBuffer) -> Bool { guard let attachments = CMSampleBufferGetSampleAttachmentsArray( sampleBuffer, @@ -526,9 +646,16 @@ struct OpenScreenScreenCaptureKitHelper { let stopTask = Task.detached { while let line = readLine() { let command = line.trimmingCharacters(in: .whitespacesAndNewlines) - if command == "stop" { + switch command { + case "pause": + recorder.pause() + case "resume": + recorder.resume() + case "stop": await recorder.stop() exit(0) + default: + break } } } diff --git a/electron/preload.ts b/electron/preload.ts index d6af7b0..393311e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -87,6 +87,12 @@ contextBridge.exposeInMainWorld("electronAPI", { startNativeMacRecording: (request: NativeMacRecordingRequest) => { return ipcRenderer.invoke("start-native-mac-recording", request); }, + pauseNativeMacRecording: () => { + return ipcRenderer.invoke("pause-native-mac-recording"); + }, + resumeNativeMacRecording: () => { + return ipcRenderer.invoke("resume-native-mac-recording"); + }, stopNativeMacRecording: (discard?: boolean) => { return ipcRenderer.invoke("stop-native-mac-recording", discard); }, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index d4fa928..f901297 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -98,6 +98,7 @@ export function LaunchWindow() { elapsedSeconds, toggleRecording, togglePaused, + canPauseRecording, restartRecording, cancelRecording, microphoneEnabled, @@ -668,13 +669,18 @@ export function LaunchWindow() { {recording && (
- - - + {canPauseRecording && ( + + + + )}