Pause native screen capture across platforms

This commit is contained in:
webadderall
2026-03-18 18:36:25 +11:00
parent d5f14f9b16
commit de1074cdff
13 changed files with 378 additions and 42 deletions
+101 -9
View File
@@ -28,7 +28,13 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
private var firstPrimaryAudioSampleTime: CMTime?
private var firstMicrophoneSampleTime: CMTime?
private var lastSampleBuffer: CMSampleBuffer?
private var lastVideoPresentationTime: CMTime = .zero
private var lastVideoDuration: CMTime = .zero
private var isRecording = false
private var isPaused = false
private var pauseStartedHostTime: CMTime?
private var pendingResumeAdjustment = false
private var accumulatedPausedDuration: CMTime = .zero
private var sessionStarted = false
private var frameCount = 0
private var outputURL: URL?
@@ -230,8 +236,14 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
assetWriter.startSession(atSourceTime: .zero)
sessionStarted = true
isRecording = true
isPaused = false
pauseStartedHostTime = nil
pendingResumeAdjustment = false
accumulatedPausedDuration = .zero
frameCount = 0
firstSampleTime = .zero
lastVideoPresentationTime = .zero
lastVideoDuration = .zero
startWindowValidationIfNeeded()
print("Recording started")
fflush(stdout)
@@ -245,8 +257,22 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
return try await finishCapture()
}
func pauseCapture() {
guard isRecording, !isPaused else { return }
isPaused = true
pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock())
pendingResumeAdjustment = false
}
func resumeCapture() {
guard isRecording, isPaused else { return }
isPaused = false
pendingResumeAdjustment = true
}
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
guard sessionStarted, sampleBuffer.isValid, isRecording else { return }
guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return }
if outputType == .screen {
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
@@ -263,11 +289,12 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
firstSampleTime = sampleBuffer.presentationTimeStamp
}
let presentationTime = sampleBuffer.presentationTimeStamp - firstSampleTime
lastSampleBuffer = sampleBuffer
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) {
videoInput.append(retimedSampleBuffer)
lastVideoPresentationTime = presentationTime
lastVideoDuration = sampleBuffer.duration
frameCount += 1
}
return
@@ -275,15 +302,15 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
if outputType == .audio {
guard primaryAudioSource == .system, let audioInput else { return }
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime)
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime, presentationTime: presentationTime)
return
}
if outputType.rawValue == microphoneOutputTypeRawValue {
if writesMicrophoneToSeparateTrack, let microphoneOnlyInput {
appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime)
appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime, presentationTime: presentationTime)
} else if primaryAudioSource == .microphone, let audioInput {
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime)
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime, presentationTime: presentationTime)
}
return
}
@@ -312,7 +339,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
stream = nil
if let originalBuffer = lastSampleBuffer, let videoInput = videoInput {
let additionalTime = CMTime(seconds: ProcessInfo.processInfo.systemUptime, preferredTimescale: 600) - firstSampleTime
let additionalTime = lastVideoPresentationTime + frameDuration(for: originalBuffer)
let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp)
if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) {
videoInput.append(additionalSampleBuffer)
@@ -340,7 +367,13 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
firstPrimaryAudioSampleTime = nil
firstMicrophoneSampleTime = nil
lastSampleBuffer = nil
lastVideoPresentationTime = .zero
lastVideoDuration = .zero
frameCount = 0
isPaused = false
pauseStartedHostTime = nil
pendingResumeAdjustment = false
accumulatedPausedDuration = .zero
capturesSystemAudio = false
capturesMicrophone = false
writesMicrophoneToSeparateTrack = false
@@ -348,16 +381,53 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
return path
}
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?) {
private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? {
if isPaused {
return nil
}
let sampleTime = sampleBuffer.presentationTimeStamp
if pendingResumeAdjustment, let pauseStartedHostTime {
let pauseGap = sampleTime - pauseStartedHostTime
if pauseGap > .zero {
accumulatedPausedDuration = accumulatedPausedDuration + pauseGap
}
self.pauseStartedHostTime = nil
pendingResumeAdjustment = false
}
if outputType == .screen {
if firstSampleTime == .zero {
firstSampleTime = sampleTime
}
return max(.zero, sampleTime - firstSampleTime - accumulatedPausedDuration)
}
return sampleTime - accumulatedPausedDuration
}
private func frameDuration(for sampleBuffer: CMSampleBuffer) -> CMTime {
if sampleBuffer.duration.isValid && sampleBuffer.duration > .zero {
return sampleBuffer.duration
}
if lastVideoDuration.isValid && lastVideoDuration > .zero {
return lastVideoDuration
}
return CMTime(value: 1, timescale: CMTimeScale(targetCaptureFPS))
}
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?, presentationTime: CMTime) {
guard input.isReadyForMoreMediaData else { return }
if firstSampleTime == nil {
firstSampleTime = sampleBuffer.presentationTimeStamp
firstSampleTime = presentationTime
}
guard let firstSampleTime else { return }
let presentationTime = sampleBuffer.presentationTimeStamp - firstSampleTime
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
let relativePresentationTime = max(.zero, presentationTime - firstSampleTime)
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: relativePresentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) {
input.append(retimedSampleBuffer)
}
@@ -457,6 +527,18 @@ final class RecorderService {
}
}
func pause() {
queue.async {
self.recorder.pauseCapture()
}
}
func resume() {
queue.async {
self.recorder.resumeCapture()
}
}
func waitUntilFinished() {
completionGroup.wait()
}
@@ -478,6 +560,16 @@ service.start(configJSON: CommandLine.arguments[1])
DispatchQueue.global(qos: .utility).async {
while let input = readLine(strippingNewline: true)?.lowercased() {
if input == "pause" {
service.pause()
continue
}
if input == "resume" {
service.resume()
continue
}
if input == "stop" {
service.stop()
break