feat: support pausing macOS native recordings
This commit is contained in:
Vendored
+8
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
+128
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user