feat: support pausing macOS native recordings

This commit is contained in:
Etienne Lescot
2026-05-12 17:14:13 +02:00
parent b2f9afab8c
commit 73870c65ef
7 changed files with 327 additions and 12 deletions
+8
View File
@@ -120,6 +120,14 @@ interface Window {
startNativeMacRecording: (
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
) => Promise<import("../src/lib/nativeMacRecording").NativeMacRecordingStartResult>;
pauseNativeMacRecording: () => Promise<{
success: boolean;
error?: string;
}>;
resumeNativeMacRecording: () => Promise<{
success: boolean;
error?: string;
}>;
stopNativeMacRecording: (discard?: boolean) => Promise<{
success: boolean;
path?: string;
+119
View File
@@ -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<void>((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);
@@ -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"
}
@@ -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
}
}
}
+6
View File
@@ -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);
},
+13 -7
View File
@@ -98,6 +98,7 @@ export function LaunchWindow() {
elapsedSeconds,
toggleRecording,
togglePaused,
canPauseRecording,
restartRecording,
cancelRecording,
microphoneEnabled,
@@ -668,13 +669,18 @@ export function LaunchWindow() {
{recording && (
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<Tooltip
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
>
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
</button>
</Tooltip>
{canPauseRecording && (
<Tooltip
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
>
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
{getIcon(
paused ? "resume" : "pause",
paused ? "text-amber-400" : "text-white/60",
)}
</button>
</Tooltip>
)}
<Tooltip content={t("tooltips.restartRecording")}>
<button className={hudAuxIconBtnClasses} onClick={restartRecording}>
{getIcon("restart", "text-white/60")}
+51
View File
@@ -55,6 +55,7 @@ type UseScreenRecorderReturn = {
elapsedSeconds: number;
toggleRecording: () => void;
togglePaused: () => void;
canPauseRecording: boolean;
restartRecording: () => void;
cancelRecording: () => void;
microphoneEnabled: boolean;
@@ -88,6 +89,7 @@ type NativeWindowsRecordingHandle = {
type NativeMacRecordingHandle = {
recordingId: number;
finalizing: boolean;
paused: boolean;
};
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
@@ -145,6 +147,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const [countdownActive, setCountdownActive] = useState(false);
const webcamReady = useRef(false);
const webcamAcquireId = useRef(0);
const canPauseRecording =
recording &&
!nativeWindowsRecording.current &&
Boolean(
(nativeMacRecording.current && !nativeMacRecording.current.finalizing) ||
(screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"),
);
const getRecordingDurationMs = useCallback(() => {
const segmentDuration =
@@ -912,6 +921,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
nativeMacRecording.current = {
recordingId: result.recordingId,
finalizing: false,
paused: false,
};
webcamRecorder.current = nativeWebcamRecorder;
accumulatedDurationMs.current = 0;
@@ -1297,6 +1307,46 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
};
const togglePaused = () => {
const activeNativeMacRecording = nativeMacRecording.current;
if (activeNativeMacRecording && !activeNativeMacRecording.finalizing) {
void (async () => {
const activeWebcamRecorder = webcamRecorder.current?.recorder;
try {
if (activeNativeMacRecording.paused) {
const result = await window.electronAPI.resumeNativeMacRecording();
if (!result.success) {
throw new Error(result.error ?? "Failed to resume native macOS recording");
}
if (activeWebcamRecorder?.state === "paused") {
activeWebcamRecorder.resume();
}
activeNativeMacRecording.paused = false;
segmentStartedAt.current = Date.now();
setPaused(false);
return;
}
const pausedAtMs = getRecordingDurationMs();
const result = await window.electronAPI.pauseNativeMacRecording();
if (!result.success) {
throw new Error(result.error ?? "Failed to pause native macOS recording");
}
if (activeWebcamRecorder?.state === "recording") {
activeWebcamRecorder.pause();
}
activeNativeMacRecording.paused = true;
accumulatedDurationMs.current = pausedAtMs;
segmentStartedAt.current = null;
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
setPaused(true);
} catch (error) {
console.error("Failed to toggle native macOS pause state:", error);
toast.error(error instanceof Error ? error.message : "Failed to toggle pause state");
}
})();
return;
}
const activeScreenRecorder = screenRecorder.current?.recorder;
if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") {
return;
@@ -1456,6 +1506,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
elapsedSeconds,
toggleRecording,
togglePaused,
canPauseRecording,
restartRecording,
cancelRecording,
microphoneEnabled,