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
@@ -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
}
}
}