import Foundation import ScreenCaptureKit import AVFoundation import CoreGraphics import CoreImage struct CaptureConfig: Codable { let fps: Int? let displayId: CGDirectDisplayID? let windowId: UInt32? let windowX: Double? let windowY: Double? let windowWidth: Double? let windowHeight: Double? /// A fixed area of `displayId` in global display points (top-left origin). let regionX: Double? let regionY: Double? let regionWidth: Double? let regionHeight: Double? let outputPath: String? let capturesSystemAudio: Bool? let capturesMicrophone: Bool? let systemAudioOutputPath: String? let microphoneDeviceId: String? let microphoneLabel: String? let microphoneOutputPath: String? let excludedProcessIds: [Int32]? } let targetCaptureFPS = 60 let maxInlineAudioTailExtension = CMTime(seconds: 2.0, preferredTimescale: 600) /// Keeps short encoder stalls from losing speech. Missing source intervals become /// silence; exceeding the retention bounds explicitly fails instead of compacting AAC audio. /// Accessed only on the recorder queue, or after capture has stopped during finish. final class AudioSampleQueue { private struct Pending { let buffer: CMSampleBuffer let time: CMTime let duration: Double let bytes: Int } private let isReady: () -> Bool private let canWrite: () -> Bool private let appendSample: (CMSampleBuffer) -> Bool private var pending: [Pending] = [] private var queuedDuration = 0.0 private var queuedBytes = 0 private var lastFormat: CMFormatDescription? private(set) var failure: Error? private(set) var endTime = CMTime.zero private(set) var droppedBuffers = 0 private(set) var silenceFrames: Int64 = 0 private(set) var peakQueuedBytes = 0 private(set) var peakQueuedDuration = 0.0 init(isReady: @escaping () -> Bool, canWrite: @escaping () -> Bool, append: @escaping (CMSampleBuffer) -> Bool) { self.isReady = isReady self.canWrite = canWrite self.appendSample = append } func enqueue(_ buffer: CMSampleBuffer, at time: CMTime) { drain() guard failure == nil, let format = buffer.formatDescription, let asbd = format.audioStreamBasicDescription, asbd.mFormatID == kAudioFormatLinearPCM, asbd.mSampleRate.isFinite, asbd.mSampleRate > 0, asbd.mSampleRate <= 384_000, asbd.mSampleRate.rounded() == asbd.mSampleRate, buffer.numSamples > 0, time.isValid, time.seconds.isFinite else { failure = failure ?? audioError("Invalid PCM audio buffer") return } lastFormat = format let duration = Double(buffer.numSamples) / asbd.mSampleRate let bytes = buffer.dataBuffer?.dataLength ?? buffer.totalSampleSize // Bound both media time and retained memory, even with unusual input formats. guard pending.count < 256, queuedDuration + duration <= 2.0, bytes <= 4 * 1024 * 1024 - queuedBytes else { droppedBuffers += 1 failure = audioError("Audio encoder queue exceeded its safe recording limit") return } pending.append(Pending(buffer: buffer, time: time, duration: duration, bytes: bytes)) queuedDuration += duration queuedBytes += bytes peakQueuedBytes = max(peakQueuedBytes, queuedBytes) peakQueuedDuration = max(peakQueuedDuration, queuedDuration) drain() } /// Pads the end too: a final dropped buffer has no later timestamp to reveal its gap. func finish(at targetTime: CMTime, timeout: TimeInterval = 5.0) async -> Error? { var deadline = ProcessInfo.processInfo.systemUptime + timeout guard targetTime.isValid, targetTime.seconds.isFinite, targetTime >= .zero else { return audioError("Invalid audio finalization time") } while failure == nil { if Task.isCancelled { failure = CancellationError(); break } let previousEnd = endTime drain(until: targetTime) if pending.isEmpty { if let format = lastFormat { fillSilence(to: targetTime, format: format) } if failure != nil || lastFormat == nil || endTime >= targetTime { break } } if endTime > previousEnd { // The timeout measures a stalled encoder, not time spent restoring audio. deadline = ProcessInfo.processInfo.systemUptime + timeout await Task.yield() continue } if ProcessInfo.processInfo.systemUptime >= deadline { failure = audioError("Audio encoder did not drain before finalization") break } do { try await Task.sleep(nanoseconds: 10_000_000) } catch { failure = error } } pending.removeAll() queuedDuration = 0 queuedBytes = 0 return failure } private func drain(until limit: CMTime? = nil) { guard failure == nil else { return } guard canWrite() else { failure = audioError("Audio writer stopped accepting data") pending.removeAll() queuedDuration = 0 queuedBytes = 0 return } while let next = pending.first, failure == nil { if let limit, next.time >= limit || endTime >= limit { removeFirst() continue } guard isReady() else { return } guard let format = next.buffer.formatDescription, let asbd = format.audioStreamBasicDescription else { failure = audioError("Missing audio format") return } let rate = CMTimeScale(asbd.mSampleRate) guard fillSilence(to: next.time, format: format) else { return } guard failure == nil, isReady() else { return } // Trim overlapping or repeated samples instead of writing time twice. let overlap = Int(min(Double(next.buffer.numSamples), max(0, ((endTime - next.time).seconds * asbd.mSampleRate).rounded()))) let skip = overlap <= 1 ? 0 : overlap // CMTime(seconds:) can round adjacent timestamps by one frame. let remainingFrames = next.buffer.numSamples - skip let count = limit.map { Int(min(Double(remainingFrames), max(0, (($0 - endTime).seconds * asbd.mSampleRate).rounded()))) } ?? remainingFrames if count > 0 { do { let buffer: CMSampleBuffer if skip > 0 || count < next.buffer.numSamples { guard let pcmFormat = pcmStorageFormat(for: format) else { throw audioError("Unsupported PCM audio format") } guard let pcm = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: AVAudioFrameCount(count)) else { throw audioError("Cannot allocate trimmed audio") } pcm.frameLength = AVAudioFrameCount(count) guard CMSampleBufferCopyPCMDataIntoAudioBufferList(next.buffer, at: Int32(skip), frameCount: Int32(count), into: pcm.mutableAudioBufferList) == noErr, let slice = makeSample(pcm, format: format, at: endTime) else { throw audioError("Cannot trim overlapping audio") } buffer = slice } else { buffer = next.buffer } let timing = CMSampleTimingInfo(duration: CMTime(value: 1, timescale: rate), presentationTimeStamp: endTime, decodeTimeStamp: .invalid) let retimed = try CMSampleBuffer(copying: buffer, withNewTiming: [timing]) guard appendSample(retimed) else { throw audioError("Cannot append audio") } endTime = endTime + CMTime(value: Int64(buffer.numSamples), timescale: rate) } catch { failure = error; return } } removeFirst() } } private func removeFirst() { let next = pending.removeFirst() queuedDuration = max(0, queuedDuration - next.duration) queuedBytes -= next.bytes } @discardableResult private func fillSilence(to target: CMTime, format: CMFormatDescription) -> Bool { guard let asbd = format.audioStreamBasicDescription else { return false } let rate = CMTimeScale(asbd.mSampleRate) guard target.isValid, target.seconds.isFinite, (target - endTime).seconds <= 120 else { failure = audioError("Audio timestamp gap exceeded the safe recovery limit") return false } // Bound work as well as memory. Long gaps resume on the next callback or finish poll. var chunksRemaining = 16 while isReady(), failure == nil, chunksRemaining > 0 { chunksRemaining -= 1 let frames = Int64(min(4096, max(0, ((target - endTime).seconds * asbd.mSampleRate).rounded()))) guard frames > 0 else { // Avoid sub-sample rounding leaving finish in a retry loop. if target > endTime { endTime = target } return true } guard let pcmFormat = pcmStorageFormat(for: format) else { failure = audioError("Unsupported PCM audio format") return false } guard let pcm = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: AVAudioFrameCount(frames)) else { failure = audioError("Cannot allocate audio silence") return false } pcm.frameLength = AVAudioFrameCount(frames) for buffer in UnsafeMutableAudioBufferListPointer(pcm.mutableAudioBufferList) { buffer.mData?.initializeMemory(as: UInt8.self, repeating: 0, count: Int(buffer.mDataByteSize)) } guard let silence = makeSample(pcm, format: format, at: endTime), appendSample(silence) else { failure = audioError("Cannot write audio silence") return false } endTime = endTime + CMTime(value: frames, timescale: rate) silenceFrames += frames } return endTime >= target } /// ScreenCaptureKit can supply multichannel microphone PCM without a layout. /// AVAudioFormat(cmAudioFormatDescription:) then returns nil despite its /// nonoptional Swift signature. Use a failable initializer and a discrete /// layout for storage, preserving the original sample format and channel order. private func pcmStorageFormat(for format: CMFormatDescription) -> AVAudioFormat? { guard var asbd = format.audioStreamBasicDescription, asbd.mFormatID == kAudioFormatLinearPCM, asbd.mChannelsPerFrame > 0, asbd.mChannelsPerFrame <= 64 else { return nil } if let layout = CMAudioFormatDescriptionGetChannelLayout(format, sizeOut: nil) { return AVAudioFormat(streamDescription: &asbd, channelLayout: AVAudioChannelLayout(layout: layout)) } if asbd.mChannelsPerFrame > 2 { guard let layout = AVAudioChannelLayout(layoutTag: kAudioChannelLayoutTag_DiscreteInOrder | asbd.mChannelsPerFrame) else { return nil } return AVAudioFormat(streamDescription: &asbd, channelLayout: layout) } return AVAudioFormat(streamDescription: &asbd) } private func makeSample(_ pcm: AVAudioPCMBuffer, format: CMFormatDescription, at time: CMTime) -> CMSampleBuffer? { var timing = CMSampleTimingInfo(duration: CMTime(value: 1, timescale: CMTimeScale(pcm.format.sampleRate)), presentationTimeStamp: time, decodeTimeStamp: .invalid) var sample: CMSampleBuffer? guard CMSampleBufferCreate(allocator: kCFAllocatorDefault, dataBuffer: nil, dataReady: false, makeDataReadyCallback: nil, refcon: nil, formatDescription: format, sampleCount: Int(pcm.frameLength), sampleTimingEntryCount: 1, sampleTimingArray: &timing, sampleSizeEntryCount: 0, sampleSizeArray: nil, sampleBufferOut: &sample) == noErr, let sample, CMSampleBufferSetDataBufferFromAudioBufferList(sample, blockBufferAllocator: nil, blockBufferMemoryAllocator: nil, flags: 0, bufferList: pcm.audioBufferList) == noErr else { return nil } return sample } private func audioError(_ message: String) -> Error { NSError(domain: "RecordlyCapture", code: 17, userInfo: [NSLocalizedDescriptionKey: message]) } } /// Bounds samples retained by asynchronous delivery before the recorder queue can inspect them. final class AudioDeliveryBudget { private let lock = NSLock() private var bytes = 0 private var duration = 0.0 private var count = 0 private var failed = false func reserve(bytes amount: Int, duration seconds: Double) -> (accepted: Bool, reportFailure: Bool) { lock.lock() defer { lock.unlock() } guard !failed else { return (false, false) } guard amount >= 0, seconds.isFinite, seconds > 0, count < 256, bytes + amount <= 4 * 1024 * 1024, duration + seconds <= 2 else { failed = true return (false, true) } bytes += amount duration += seconds count += 1 return (true, false) } func release(bytes amount: Int, duration seconds: Double) { lock.lock() defer { lock.unlock() } bytes = max(0, bytes - amount) duration = max(0, duration - seconds) count -= 1 } } func matchMicrophoneCaptureDeviceID(label: String?, deviceId: String?, devices: [(name: String, uniqueID: String)]) -> String? { let identifier = deviceId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !identifier.isEmpty, let exact = devices.first(where: { $0.uniqueID == identifier }) { return exact.uniqueID } let name = label?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !name.isEmpty else { return nil } let matches = devices.filter { $0.name == name } if !matches.isEmpty { return matches.count == 1 ? matches[0].uniqueID : nil } // Chromium appends transport details, e.g. "USB Mic (14ed:1012)". // Preserve native-ID priority and reject ambiguous fallback names. guard name.hasSuffix(")"), let suffix = name.range(of: " (", options: .backwards) else { return nil } let undecorated = name[.. let interactiveStopParticipated: Bool } private let queue = DispatchQueue(label: "recordly.screencapturekit.video") private let audioDeliveryQueue = DispatchQueue(label: "recordly.screencapturekit.audio-delivery") private let audioDeliveryBudget = AudioDeliveryBudget() private var audioSampleQueues: [ObjectIdentifier: AudioSampleQueue] = [:] private var captureFailure: Error? private var stopTimelineTime: CMTime? private var stillFrameTimer: DispatchSourceTimer? private var assetWriter: AVAssetWriter? private var videoInput: AVAssetWriterInput? private var videoPixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor? private var windowCropRect: CGRect? private var windowCropDisplayId: CGDirectDisplayID? private var excludedProcessIds = Set() private var captureDisplayId: CGDirectDisplayID? private var lastCroppedPixelBuffer: CVPixelBuffer? private let imageContext = CIContext(options: [.cacheIntermediates: false]) private var systemAudioWriter: AVAssetWriter? private var systemAudioInput: AVAssetWriterInput? private var microphoneOnlyWriter: AVAssetWriter? private var microphoneOnlyInput: AVAssetWriterInput? private var stream: SCStream? private var firstSampleTime: CMTime = .zero private var firstSystemAudioSampleTime: CMTime? private var firstMicrophoneSampleTime: CMTime? private var lastSystemAudioPresentationTime: CMTime = .invalid private var lastMicrophonePresentationTime: CMTime = .invalid private var lastSampleBuffer: CMSampleBuffer? private var lastVideoPresentationTime: CMTime = .zero private var lastVideoDuration: CMTime = .zero private var lastInlineAudioPresentationTime: CMTime = .invalid private var lastInlineAudioDuration: 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? private var microphoneOutputURL: URL? private var trackedWindowId: UInt32? private var windowValidationTask: Task? private var isFinalizing = false private var interactiveStopParticipated = false private var finalizationWaiters: [CheckedContinuation] = [] private var inlineAudioInput: AVAssetWriterInput? private var firstInlineAudioSampleTime: CMTime? private var capturesSystemAudio = false private var capturesMicrophone = false private var writesSystemAudioToSeparateTrack = false private var writesMicrophoneToSeparateTrack = false private let microphoneOutputTypeRawValue = 2 func startCapture(configJSON: String) async throws { guard !isRecording else { throw NSError(domain: "RecordlyCapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "Recording is already in progress"]) } guard let data = configJSON.data(using: .utf8) else { throw NSError(domain: "RecordlyCapture", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON input"]) } let config = try JSONDecoder().decode(CaptureConfig.self, from: data) let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) let streamConfig = SCStreamConfiguration() capturesSystemAudio = config.capturesSystemAudio ?? false capturesMicrophone = config.capturesMicrophone ?? false if capturesMicrophone && !supportsNativeMicrophoneCapture(streamConfig: streamConfig) { fputs("MICROPHONE_CAPTURE_UNAVAILABLE\n", stderr) fflush(stderr) capturesMicrophone = false } if capturesMicrophone { let selectedID = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let selectedLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let explicitSelection = (!selectedID.isEmpty && selectedID != "default" && selectedID != "communications") || (selectedID.isEmpty && !selectedLabel.isEmpty) if let deviceID = Self.resolveMicrophoneCaptureDeviceID(config: config) { streamConfig.setValue(deviceID, forKey: "microphoneCaptureDeviceID") } else if explicitSelection { fputs("MICROPHONE_DEVICE_UNAVAILABLE\n", stderr) fflush(stderr) capturesMicrophone = false } } writesSystemAudioToSeparateTrack = capturesSystemAudio writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS) streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS)) streamConfig.queueDepth = 6 streamConfig.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange streamConfig.colorSpaceName = CGColorSpace.sRGB streamConfig.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2 streamConfig.showsCursor = false streamConfig.capturesAudio = capturesSystemAudio || capturesMicrophone streamConfig.sampleRate = 48000 streamConfig.channelCount = 2 streamConfig.excludesCurrentProcessAudio = true if capturesMicrophone { streamConfig.setValue(true, forKey: "captureMicrophone") } let filter: SCContentFilter let outputWidth: Int let outputHeight: Int excludedProcessIds = Set(config.excludedProcessIds ?? []) let excludedApplications = availableContent.applications.filter { excludedProcessIds.contains($0.processID) } // A window or an area records the part of its display inside a frame, so menus // and popovers over a window are captured just as they appear on screen. let requestedFrame: CGRect? if let windowId = config.windowId { trackedWindowId = windowId guard let window = availableContent.windows.first(where: { $0.windowID == windowId }) else { throw NSError(domain: "RecordlyCapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Window not found"]) } // Accessibility reports the visible frame at the native border. if let x = config.windowX, let y = config.windowY, let width = config.windowWidth, let height = config.windowHeight, width > 0, height > 0 { requestedFrame = CGRect(x: x, y: y, width: width, height: height) } else { requestedFrame = window.frame } } else if let x = config.regionX, let y = config.regionY, let width = config.regionWidth, let height = config.regionHeight, width > 0, height > 0 { trackedWindowId = nil requestedFrame = CGRect(x: x, y: y, width: width, height: height) } else { requestedFrame = nil } if let visibleFrame = requestedFrame { let preferredDisplay = config.windowId == nil ? config.displayId.flatMap { displayId in availableContent.displays.first(where: { $0.displayID == displayId }) } : nil guard let display = preferredDisplay ?? Self.captureDisplay(for: visibleFrame, from: availableContent.displays) else { throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Window display not found"]) } let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) let captureRect = visibleFrame.intersection(display.frame) guard captureRect.width >= 2, captureRect.height >= 2 else { throw NSError(domain: "RecordlyCapture", code: 18, userInfo: [NSLocalizedDescriptionKey: "Capture area is outside the display"]) } filter = SCContentFilter( display: display, excludingApplications: excludedApplications, exceptingWindows: [] ) windowCropRect = CGRect( x: (captureRect.minX - display.frame.minX) / display.frame.width, y: (captureRect.minY - display.frame.minY) / display.frame.height, width: captureRect.width / display.frame.width, height: captureRect.height / display.frame.height ) windowCropDisplayId = display.displayID captureDisplayId = display.displayID outputWidth = max(2, Int(captureRect.width) * scaleFactor) & ~1 outputHeight = max(2, Int(captureRect.height) * scaleFactor) & ~1 streamConfig.width = max(2, Int(display.frame.width) * scaleFactor) streamConfig.height = max(2, Int(display.frame.height) * scaleFactor) streamConfig.pixelFormat = kCVPixelFormatType_32BGRA } else { trackedWindowId = nil windowCropRect = nil windowCropDisplayId = nil let displayId = config.displayId ?? CGMainDisplayID() guard let display = availableContent.displays.first(where: { $0.displayID == displayId }) else { throw NSError(domain: "RecordlyCapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "Display not found"]) } filter = SCContentFilter( display: display, excludingApplications: excludedApplications, exceptingWindows: [] ) let displayBounds = CGDisplayBounds(display.displayID) captureDisplayId = display.displayID let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: display.displayID) outputWidth = max(2, Int(displayBounds.width) * scaleFactor) outputHeight = max(2, Int(displayBounds.height) * scaleFactor) streamConfig.width = outputWidth streamConfig.height = outputHeight } let destinationURL: URL if let outputPath = config.outputPath, !outputPath.isEmpty { destinationURL = URL(fileURLWithPath: outputPath) } else { destinationURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .appendingPathComponent("output_\(Int(Date().timeIntervalSince1970)).mp4") } outputURL = destinationURL let outputFileType: AVFileType = destinationURL.pathExtension.lowercased() == "mp4" ? .mp4 : .mov assetWriter = try AVAssetWriter(url: destinationURL, fileType: outputFileType) microphoneOutputURL = nil firstSystemAudioSampleTime = nil firstMicrophoneSampleTime = nil lastSystemAudioPresentationTime = .invalid lastMicrophonePresentationTime = .invalid guard let assistant = AVOutputSettingsAssistant(preset: .preset3840x2160) else { throw NSError(domain: "RecordlyCapture", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unable to create output settings assistant"]) } let sourceVideoFormat = try CMVideoFormatDescription( videoCodecType: CMFormatDescription.MediaSubType( rawValue: windowCropRect == nil ? kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange : kCVPixelFormatType_32BGRA ), width: outputWidth, height: outputHeight ) assistant.sourceVideoFormat = sourceVideoFormat guard var outputSettings = assistant.videoSettings else { throw NSError(domain: "RecordlyCapture", code: 6, userInfo: [NSLocalizedDescriptionKey: "Output settings unavailable"]) } outputSettings[AVVideoWidthKey] = outputWidth outputSettings[AVVideoHeightKey] = outputHeight // Preserve detail in small UI text before the recording reaches the editor. // Give Retina pixels and 60 FPS an explicit bitrate budget. var compression = outputSettings[AVVideoCompressionPropertiesKey] as? [String: Any] ?? [:] compression.merge( Self.videoCompressionSettings(width: outputWidth, height: outputHeight, fps: requestedFPS) ) { _, requested in requested } outputSettings[AVVideoCompressionPropertiesKey] = compression outputSettings[AVVideoColorPropertiesKey] = [ AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2, AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2, AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2, ] let videoInput = AVAssetWriterInput( mediaType: .video, outputSettings: outputSettings, sourceFormatHint: sourceVideoFormat ) videoInput.expectsMediaDataInRealTime = true guard let assetWriter = assetWriter, assetWriter.canAdd(videoInput) else { throw NSError(domain: "RecordlyCapture", code: 7, userInfo: [NSLocalizedDescriptionKey: "Unable to add video writer input"]) } assetWriter.add(videoInput) self.videoInput = videoInput videoPixelBufferAdaptor = windowCropRect.map { _ in AVAssetWriterInputPixelBufferAdaptor( assetWriterInput: videoInput, sourcePixelBufferAttributes: [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, kCVPixelBufferWidthKey as String: outputWidth, kCVPixelBufferHeightKey as String: outputHeight, ] ) } // Add inline audio track directly to the video so the .mp4 always contains audio. // This eliminates the dependency on the post-recording ffmpeg mux step. if capturesSystemAudio || capturesMicrophone { let inlineAudio = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 192_000)) inlineAudio.expectsMediaDataInRealTime = true if assetWriter.canAdd(inlineAudio) { assetWriter.add(inlineAudio) self.inlineAudioInput = inlineAudio } } if writesSystemAudioToSeparateTrack { guard let systemAudioOutputPath = config.systemAudioOutputPath, !systemAudioOutputPath.isEmpty else { throw NSError(domain: "RecordlyCapture", code: 11, userInfo: [NSLocalizedDescriptionKey: "Missing system audio output path for audio capture"]) } let systemAudioURL = URL(fileURLWithPath: systemAudioOutputPath) let systemAudioWriter = try AVAssetWriter(url: systemAudioURL, fileType: .m4a) let systemAudioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 160_000)) systemAudioInput.expectsMediaDataInRealTime = true guard systemAudioWriter.canAdd(systemAudioInput) else { throw NSError(domain: "RecordlyCapture", code: 12, userInfo: [NSLocalizedDescriptionKey: "Unable to add system audio writer input"]) } systemAudioWriter.add(systemAudioInput) self.systemAudioWriter = systemAudioWriter self.systemAudioInput = systemAudioInput guard systemAudioWriter.startWriting() else { throw NSError(domain: "RecordlyCapture", code: 13, userInfo: [NSLocalizedDescriptionKey: systemAudioWriter.error?.localizedDescription ?? "Unable to start system audio writing"]) } systemAudioWriter.startSession(atSourceTime: .zero) } if writesMicrophoneToSeparateTrack { guard let microphoneOutputPath = config.microphoneOutputPath, !microphoneOutputPath.isEmpty else { throw NSError(domain: "RecordlyCapture", code: 14, userInfo: [NSLocalizedDescriptionKey: "Missing microphone output path for microphone capture"]) } let microphoneURL = URL(fileURLWithPath: microphoneOutputPath) microphoneOutputURL = microphoneURL let microphoneWriter = try AVAssetWriter(url: microphoneURL, fileType: .m4a) let microphoneInput = AVAssetWriterInput(mediaType: .audio, outputSettings: Self.audioOutputSettings(bitRate: 128_000)) microphoneInput.expectsMediaDataInRealTime = true guard microphoneWriter.canAdd(microphoneInput) else { throw NSError(domain: "RecordlyCapture", code: 15, userInfo: [NSLocalizedDescriptionKey: "Unable to add microphone writer input"]) } microphoneWriter.add(microphoneInput) self.microphoneOnlyWriter = microphoneWriter self.microphoneOnlyInput = microphoneInput guard microphoneWriter.startWriting() else { throw NSError(domain: "RecordlyCapture", code: 16, userInfo: [NSLocalizedDescriptionKey: microphoneWriter.error?.localizedDescription ?? "Unable to start microphone audio writing"]) } microphoneWriter.startSession(atSourceTime: .zero) } let stream = SCStream(filter: filter, configuration: streamConfig, delegate: self) self.stream = stream try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue) if capturesSystemAudio { try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: audioDeliveryQueue) } if capturesMicrophone { guard let microphoneOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) else { throw NSError( domain: "RecordlyCapture", code: 17, userInfo: [NSLocalizedDescriptionKey: "Microphone stream output type is unavailable"] ) } try stream.addStreamOutput(self, type: microphoneOutputType, sampleHandlerQueue: audioDeliveryQueue) } try await stream.startCapture() guard assetWriter.startWriting() else { throw NSError(domain: "RecordlyCapture", code: 8, userInfo: [NSLocalizedDescriptionKey: assetWriter.error?.localizedDescription ?? "Unable to start video writing"]) } 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() startStillFrameTimer() } func stopCapture() async throws -> String { let finalization = await finalizeCapture(interactive: true) return try finalization.outputResult.get() } func pauseCapture() async -> Bool { await withCheckedContinuation { continuation in queue.async { guard self.isRecording, !self.isPaused else { continuation.resume(returning: self.isRecording && self.isPaused) return } self.isPaused = true self.pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock()) self.pendingResumeAdjustment = false continuation.resume(returning: true) } } } func resumeCapture() async -> Bool { await withCheckedContinuation { continuation in queue.async { guard self.isRecording, self.isPaused else { continuation.resume(returning: self.isRecording && !self.isPaused) return } self.isPaused = false self.pendingResumeAdjustment = true continuation.resume(returning: true) } } } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { if outputType != .screen { deliverAudioSampleBuffer(sampleBuffer, of: outputType) } else { handleSampleBuffer(sampleBuffer, of: outputType) } } private func deliverAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { // ScreenCaptureKit may send invalid or empty callbacks during a transition. // Discard these before reserving media time; they are not an overload. guard sampleBuffer.isValid, sampleBuffer.numSamples > 0 else { return } let bytes = sampleBuffer.dataBuffer?.dataLength ?? sampleBuffer.totalSampleSize let rate = sampleBuffer.formatDescription?.audioStreamBasicDescription?.mSampleRate ?? 0 let duration = rate > 0 ? Double(sampleBuffer.numSamples) / rate : 0 let reservation = audioDeliveryBudget.reserve(bytes: bytes, duration: duration) guard reservation.accepted else { if reservation.reportFailure { queue.async { self.interruptCapture(NSError(domain: "RecordlyCapture", code: 18, userInfo: [NSLocalizedDescriptionKey: "Audio delivery exceeded its safe recording limit"])) } } return } queue.async { defer { self.audioDeliveryBudget.release(bytes: bytes, duration: duration) } self.handleSampleBuffer(sampleBuffer, of: outputType) } } private func handleSampleBuffer(_ sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) { guard sessionStarted, sampleBuffer.isValid, isRecording else { return } if outputType == .screen { guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]], let attachment = attachments.first, let statusRawValue = attachment[SCStreamFrameInfo.status] as? Int, let status = SCFrameStatus(rawValue: statusRawValue), status == .complete else { return } guard let videoInput = videoInput, assetWriter?.status == .writing, videoInput.isReadyForMoreMediaData else { return } // Only a complete frame that the writer can accept may establish time zero. guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return } if frameCount > 0 && CMTimeCompare(presentationTime, lastVideoPresentationTime) <= 0 { return } lastSampleBuffer = sampleBuffer let appended: Bool if videoPixelBufferAdaptor != nil { appended = appendCroppedVideoFrame(sampleBuffer, at: presentationTime) } else { let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp) if let retimed = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) { appended = videoInput.append(retimed) } else { appended = false } } if appended { lastVideoPresentationTime = presentationTime lastVideoDuration = sampleBuffer.duration frameCount += 1 if frameCount == 1 { // Signal readiness only after AVAssetWriter has accepted a // real frame, so countdown warm-start cannot pause too early. print("Recording started") fflush(stdout) } } else if frameCount == 0 { // A failed crop/append must not leave an empty interval before frame one. firstSampleTime = .zero } return } guard frameCount > 0, let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return } if outputType == .audio { guard let systemAudioInput else { return } appendAudioSampleBuffer(sampleBuffer, to: systemAudioInput, of: systemAudioWriter, firstSampleTime: &firstSystemAudioSampleTime, lastPresentationTime: &lastSystemAudioPresentationTime, presentationTime: presentationTime) // Also write system audio to the inline video track if let inlineAudioInput { appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, lastPresentationTime: &lastInlineAudioPresentationTime, presentationTime: presentationTime) } return } if outputType.rawValue == microphoneOutputTypeRawValue { if let microphoneOnlyInput { appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, of: microphoneOnlyWriter, firstSampleTime: &firstMicrophoneSampleTime, lastPresentationTime: &lastMicrophonePresentationTime, presentationTime: presentationTime) } // Write mic to inline video track only if there's no system audio (avoids double-writing) if !capturesSystemAudio, let inlineAudioInput { appendAudioSampleBuffer(sampleBuffer, to: inlineAudioInput, of: assetWriter, firstSampleTime: &firstInlineAudioSampleTime, lastPresentationTime: &lastInlineAudioPresentationTime, presentationTime: presentationTime) } return } return } private func appendCroppedVideoFrame(_ sampleBuffer: CMSampleBuffer, at presentationTime: CMTime) -> Bool { guard let crop = windowCropRect, let adaptor = videoPixelBufferAdaptor, let pool = adaptor.pixelBufferPool, let source = CMSampleBufferGetImageBuffer(sampleBuffer) else { return false } var destination: CVPixelBuffer? guard CVPixelBufferPoolCreatePixelBuffer(nil, pool, &destination) == kCVReturnSuccess, let destination else { return false } let sourceWidth = CGFloat(CVPixelBufferGetWidth(source)) let sourceHeight = CGFloat(CVPixelBufferGetHeight(source)) let sourceRect = CGRect( x: crop.minX * sourceWidth, y: (1 - crop.maxY) * sourceHeight, width: crop.width * sourceWidth, height: crop.height * sourceHeight ) let destinationSize = CGSize( width: CVPixelBufferGetWidth(destination), height: CVPixelBufferGetHeight(destination) ) let image = CIImage(cvPixelBuffer: source) .cropped(to: sourceRect) .transformed(by: CGAffineTransform(translationX: -sourceRect.minX, y: -sourceRect.minY)) .transformed(by: CGAffineTransform( scaleX: destinationSize.width / sourceRect.width, y: destinationSize.height / sourceRect.height )) let bounds = CGRect(origin: .zero, size: destinationSize) imageContext.render( image, to: destination, bounds: bounds, colorSpace: CGColorSpace(name: CGColorSpace.sRGB) ) let appended = adaptor.append(destination, withPresentationTime: presentationTime) if appended { lastCroppedPixelBuffer = destination } return appended } func stream(_ stream: SCStream, didStopWithError error: Error) { queue.async { self.interruptCapture(error) } } private func interruptCapture(_ error: Error) { guard isRecording, captureFailure == nil else { return } captureFailure = error let partialPath = outputURL?.path ?? "" fputs("CAPTURE_INTERRUPTED: \(error.localizedDescription)\n", stderr) fflush(stderr) Task { let finalization = await self.finalizeCapture(interactive: false) print("Recording interrupted. Output path: \(partialPath)") fflush(stdout) if !finalization.interactiveStopParticipated { exit(1) } } } /// Starts one finalization operation after all previously delivered samples on /// the recorder queue have drained. Manual stop and automatic window-close /// detection join the same operation instead of racing the asset writers. private func finalizeCapture(interactive: Bool) async -> CaptureFinalizationResult { await withCheckedContinuation { (continuation: CheckedContinuation) in audioDeliveryQueue.async { continuation.resume() } } return await withCheckedContinuation { continuation in queue.async { if self.isFinalizing { self.interactiveStopParticipated = self.interactiveStopParticipated || interactive self.finalizationWaiters.append(continuation) return } guard self.isRecording else { continuation.resume(returning: CaptureFinalizationResult( outputResult: .failure(NSError( domain: "RecordlyCapture", code: 9, userInfo: [NSLocalizedDescriptionKey: "No recording in progress"] )), interactiveStopParticipated: interactive )) return } self.isFinalizing = true self.interactiveStopParticipated = interactive self.stopTimelineTime = self.timelineTimeOfStop() self.stillFrameTimer?.cancel() self.stillFrameTimer = nil self.isRecording = false self.windowValidationTask = nil self.trackedWindowId = nil self.finalizationWaiters.append(continuation) Task { let outputResult: Result do { outputResult = .success(try await self.finishCapture()) } catch { outputResult = .failure(error) } self.queue.async { let finalizationResult = CaptureFinalizationResult( outputResult: outputResult, interactiveStopParticipated: self.interactiveStopParticipated ) let waiters = self.finalizationWaiters self.finalizationWaiters.removeAll() self.isFinalizing = false self.interactiveStopParticipated = false for waiter in waiters { waiter.resume(returning: finalizationResult) } } } } } } private func finishCapture() async throws -> String { if let activeStream = stream { do { try await activeStream.stopCapture() } catch { // Stream may have already been stopped by the system — continue with file finalization } } stream = nil let videoEndTime = lastVideoPresentationTime + (lastSampleBuffer.map { frameDuration(for: $0) } ?? .zero) let endTime = stopTimelineTime.map { max($0, videoEndTime) } ?? resolvedCaptureEndTime(videoEndTime: videoEndTime) // The tail frame only gives the last captured frame its full duration, so // it must never put the file at risk. Appending to an input whose encoder // queue is still backed up — routine after a long high-resolution capture — // raises an Objective-C exception that Swift cannot catch, aborting the // helper before `finishWriting()` and leaving an mdat with no moov atom: // an unplayable recording. Wait briefly for the queue to drain, then skip // the frame rather than lose the recording. if let originalBuffer = lastSampleBuffer, let videoInput = videoInput, await waitUntilReady(videoInput, of: assetWriter) { let tailDuration = frameDuration(for: originalBuffer) let additionalTime = max(lastVideoPresentationTime + tailDuration, endTime - tailDuration) if let adaptor = videoPixelBufferAdaptor, let pixelBuffer = lastCroppedPixelBuffer { adaptor.append(pixelBuffer, withPresentationTime: additionalTime) } else { let timing = CMSampleTimingInfo(duration: tailDuration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp) if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) { videoInput.append(additionalSampleBuffer) } } } // No more video is appended after the tail. Close this lane before padding // audio: AVAssetWriter can otherwise hold audio readiness waiting for video // interleaving, leaving the final audio queue unable to drain. if assetWriter?.status == .writing { videoInput?.markAsFinished() } // `endSession`, `markAsFinished` and `finishWriting` all raise when the // writer is no longer in the `.writing` state (a mid-capture failure, for // example a full disk), which would abort the helper the same way. var audioFailure: Error? for samples in audioSampleQueues.values { if let error = await samples.finish(at: endTime) { audioFailure = audioFailure ?? error } if samples.silenceFrames > 0 { fputs("AUDIO_GAPS: silenceFramesInserted=\(samples.silenceFrames)\n", stderr) fflush(stderr) } } for input in [inlineAudioInput, systemAudioInput, microphoneOnlyInput].compactMap({ $0 }) { if audioSampleQueues[ObjectIdentifier(input)] == nil { audioFailure = audioFailure ?? NSError(domain: "RecordlyCapture", code: 19, userInfo: [NSLocalizedDescriptionKey: "Requested audio stream delivered no samples"]) } } audioSampleQueues.removeAll() if let assetWriter, assetWriter.status == .writing { assetWriter.endSession(atSourceTime: endTime) inlineAudioInput?.markAsFinished() await assetWriter.finishWriting() } if let systemAudioWriter, systemAudioWriter.status == .writing { systemAudioInput?.markAsFinished() await systemAudioWriter.finishWriting() } if let microphoneOnlyWriter, microphoneOnlyWriter.status == .writing { microphoneOnlyInput?.markAsFinished() await microphoneOnlyWriter.finishWriting() } let finalizeFailure: Error? = [assetWriter, systemAudioWriter, microphoneOnlyWriter] .compactMap { $0 } .compactMap { writer in writer.status == .completed ? nil : (writer.error ?? unfinalizedWriterError(status: writer.status)) } .first ?? captureFailure ?? audioFailure let path = outputURL?.path ?? "" captureFailure = nil stopTimelineTime = nil assetWriter = nil videoInput = nil videoPixelBufferAdaptor = nil windowCropRect = nil windowCropDisplayId = nil excludedProcessIds.removeAll() lastCroppedPixelBuffer = nil systemAudioWriter = nil systemAudioInput = nil microphoneOnlyWriter = nil microphoneOnlyInput = nil inlineAudioInput = nil outputURL = nil microphoneOutputURL = nil sessionStarted = false firstSampleTime = .zero firstSystemAudioSampleTime = nil firstMicrophoneSampleTime = nil lastSystemAudioPresentationTime = .invalid lastMicrophonePresentationTime = .invalid firstInlineAudioSampleTime = nil lastSampleBuffer = nil lastVideoPresentationTime = .zero lastVideoDuration = .zero lastInlineAudioPresentationTime = .invalid lastInlineAudioDuration = .zero frameCount = 0 isPaused = false pauseStartedHostTime = nil pendingResumeAdjustment = false accumulatedPausedDuration = .zero capturesSystemAudio = false capturesMicrophone = false writesSystemAudioToSeparateTrack = false writesMicrophoneToSeparateTrack = false // Report a half-written file as a failure instead of handing the editor a // path it cannot decode. if let finalizeFailure { throw finalizeFailure } return path } /// Waits briefly for an input's encoder queue to drain. Returns false when the /// input stays backed up or its writer is no longer accepting data, in which /// case the caller must skip the append: `AVAssetWriterInput.append` raises an /// uncatchable Objective-C exception in both cases. private func waitUntilReady(_ input: AVAssetWriterInput, of writer: AVAssetWriter?) async -> Bool { guard let writer else { return false } var attemptsRemaining = writerReadinessPollAttempts while writer.status == .writing { if input.isReadyForMoreMediaData { return true } guard attemptsRemaining > 0 else { return false } attemptsRemaining -= 1 do { try await Task.sleep(nanoseconds: writerReadinessPollInterval) } catch is CancellationError { return false } catch { return false } } return false } private func unfinalizedWriterError(status: AVAssetWriter.Status) -> Error { NSError(domain: "RecordlyCapture", code: 10, userInfo: [ NSLocalizedDescriptionKey: "Recording could not be finalized (writer status \(status.rawValue))", ]) } private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? { if isPaused { return nil } let sampleTime = sampleBuffer.presentationTimeStamp if pendingResumeAdjustment { // Audio and video callbacks share this queue but their timestamps can be // offset slightly. Anchor the post-countdown adjustment to video and drop // audio until that anchor exists; otherwise the first audio callback can // make the following video timestamp move backwards and fail the writer. guard outputType == .screen, let pauseStartedHostTime else { return nil } let pauseGap = sampleTime - pauseStartedHostTime if pauseGap > .zero { accumulatedPausedDuration = accumulatedPausedDuration + pauseGap } self.pauseStartedHostTime = nil pendingResumeAdjustment = false } if outputType == .screen { if firstSampleTime == .zero { firstSampleTime = sampleTime } } // Use video's first sample time as the common time base for ALL tracks. // This ensures audio files contain leading silence when audio hardware // delivers its first sample after the first video frame (e.g. iPhone mic // over Continuity Camera can lag 1-2 seconds behind). if firstSampleTime == .zero { // Video hasn't started yet — drop this audio sample to avoid // negative timestamps. return nil } let presentationTime = sampleTime - firstSampleTime - accumulatedPausedDuration // Audio buffers can straddle the first video frame. Preserve their signed // start so AudioSampleQueue trims only pre-origin samples; clamping here // shifts that speech forward and overlaps the next packet. return outputType == .screen ? max(.zero, presentationTime) : presentationTime } /// ScreenCaptureKit sends no frames while the screen does not change, so a still /// stretch would end the video early and cut off the audio recorded over it. private func startStillFrameTimer() { stillFrameTimer?.cancel() let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + 1.0 / 2, repeating: 1.0 / 2) timer.setEventHandler { [weak self] in self?.appendStillFrameIfIdle() } timer.resume() stillFrameTimer = timer } /// Writes the last frame again once the screen has been still for a second. The /// repeat is stamped slightly in the past, so a real frame still on its way stays /// newer than it. Runs on the recorder queue. private func appendStillFrameIfIdle() { guard isRecording, frameCount > 0, !isPaused, !pendingResumeAdjustment, let lastSampleBuffer, let videoInput, assetWriter?.status == .writing, videoInput.isReadyForMoreMediaData, let now = timelineTime(atHostTime: CMClockGetTime(CMClockGetHostTimeClock())) else { return } let repeatTime = now - CMTime(seconds: 0.1, preferredTimescale: 600) guard CMTimeCompare(repeatTime - lastVideoPresentationTime, CMTime(seconds: 1, preferredTimescale: 600)) >= 0 else { return } let appended: Bool if let adaptor = videoPixelBufferAdaptor { guard let pixelBuffer = lastCroppedPixelBuffer else { return } appended = adaptor.append(pixelBuffer, withPresentationTime: repeatTime) } else { let timing = CMSampleTimingInfo(duration: lastSampleBuffer.duration, presentationTimeStamp: repeatTime, decodeTimeStamp: lastSampleBuffer.decodeTimeStamp) guard let repeated = try? CMSampleBuffer(copying: lastSampleBuffer, withNewTiming: [timing]) else { return } appended = videoInput.append(repeated) } if appended { lastVideoPresentationTime = repeatTime } } /// Where the recording ends: now, or where it was paused. Nil before the first /// frame, and after a resume that no frame has anchored yet. private func timelineTimeOfStop() -> CMTime? { guard !pendingResumeAdjustment else { return nil } let stopHostTime = isPaused ? pauseStartedHostTime : CMClockGetTime(CMClockGetHostTimeClock()) return stopHostTime.flatMap { timelineTime(atHostTime: $0) } } /// Maps a host clock time onto the recording timeline, as `adjustedPresentationTime` /// does for sample timestamps. private func timelineTime(atHostTime hostTime: CMTime) -> CMTime? { guard firstSampleTime != .zero else { return nil } return max(.zero, hostTime - firstSampleTime - 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 latestInlineAudioEndTime() -> CMTime { guard lastInlineAudioPresentationTime.isValid else { return .invalid } if lastInlineAudioDuration.isValid && lastInlineAudioDuration > .zero { return lastInlineAudioPresentationTime + lastInlineAudioDuration } return lastInlineAudioPresentationTime } private func resolvedCaptureEndTime(videoEndTime: CMTime) -> CMTime { let inlineAudioEndTime = latestInlineAudioEndTime() guard inlineAudioEndTime.isValid else { return videoEndTime } if CMTimeCompare(inlineAudioEndTime, videoEndTime) <= 0 { return videoEndTime } // Prevent a stray inline-audio timestamp from forcing finishWriting // to finalize an arbitrarily long tail. let tailExtension = CMTimeSubtract(inlineAudioEndTime, videoEndTime) return videoEndTime + CMTimeMinimum(tailExtension, maxInlineAudioTailExtension) } private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, of writer: AVAssetWriter?, firstSampleTime: inout CMTime?, lastPresentationTime: inout CMTime, presentationTime: CMTime) { guard let writer else { return } guard writer.status == .writing else { interruptCapture(writer.error ?? unfinalizedWriterError(status: writer.status)) return } guard !lastPresentationTime.isValid || CMTimeCompare(presentationTime, lastPresentationTime) > 0 else { return } let key = ObjectIdentifier(input) let samples: AudioSampleQueue if let existing = audioSampleQueues[key] { samples = existing } else { samples = AudioSampleQueue( isReady: { [weak writer] in writer?.status == .writing && input.isReadyForMoreMediaData }, canWrite: { [weak writer] in writer?.status == .writing }, append: { [weak writer] sample in guard writer?.status == .writing, input.isReadyForMoreMediaData else { return false } return input.append(sample) }) audioSampleQueues[key] = samples } samples.enqueue(sampleBuffer, at: presentationTime) if let error = samples.failure { interruptCapture(error) } if firstSampleTime == nil { firstSampleTime = presentationTime } // Track offered time, including queued samples, when calculating the MP4 tail. lastPresentationTime = presentationTime if input === inlineAudioInput { lastInlineAudioDuration = sampleBuffer.duration } } static func videoCompressionSettings(width: Int, height: Int, fps: Int) -> [String: Any] { let pixelsPerSecond = Double(width) * Double(height) * Double(fps) return [ AVVideoAverageBitRateKey: max(8_000_000, Int((pixelsPerSecond * 0.15).rounded())), AVVideoExpectedSourceFrameRateKey: fps, AVVideoMaxKeyFrameIntervalDurationKey: 2, ] } private static func audioOutputSettings(bitRate: Int) -> [String: Any] { [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 48_000, AVNumberOfChannelsKey: 2, AVEncoderBitRateKey: bitRate, ] } private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? { matchMicrophoneCaptureDeviceID(label: config.microphoneLabel, deviceId: config.microphoneDeviceId, devices: AVCaptureDevice.devices(for: .audio).map { (name: $0.localizedName, uniqueID: $0.uniqueID) }) } private func supportsNativeMicrophoneCapture(streamConfig: SCStreamConfiguration) -> Bool { let supportsConfigSelector = streamConfig.responds(to: Selector(("setCaptureMicrophone:"))) let supportsDeviceSelector = streamConfig.responds(to: Selector(("setMicrophoneCaptureDeviceID:"))) let supportsOutputType = SCStreamOutputType(rawValue: microphoneOutputTypeRawValue) != nil return supportsConfigSelector && supportsDeviceSelector && supportsOutputType } func updateHudExclusions(_ processIds: [Int32]) async throws { // Finish any in-flight display migration before replacing its filter. let validation = windowValidationTask validation?.cancel() await validation?.value defer { if isRecording { startWindowValidationIfNeeded() } } guard isRecording, let stream, let displayId = windowCropDisplayId ?? captureDisplayId else { throw NSError(domain: "RecordlyCapture", code: 19, userInfo: [NSLocalizedDescriptionKey: "No active capture display"]) } let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) guard let display = content.displays.first(where: { $0.displayID == displayId }) else { throw NSError(domain: "RecordlyCapture", code: 20, userInfo: [NSLocalizedDescriptionKey: "Capture display unavailable"]) } let exclusions = Set(processIds) let filter = SCContentFilter(display: display, excludingApplications: content.applications.filter { exclusions.contains($0.processID) }, exceptingWindows: []) try await stream.updateContentFilter(filter) excludedProcessIds = exclusions } private func startWindowValidationIfNeeded() { guard let trackedWindowId else { windowValidationTask?.cancel() windowValidationTask = nil return } windowValidationTask?.cancel() windowValidationTask = Task.detached(priority: .utility) { [weak self] in guard let self else { return } while !Task.isCancelled { try? await Task.sleep(nanoseconds: 500_000_000) if Task.isCancelled { return } guard self.isRecording else { return } let availableContent: SCShareableContent do { availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) } catch { continue } guard let window = availableContent.windows.first(where: { $0.windowID == trackedWindowId }) else { print("WINDOW_UNAVAILABLE") fflush(stdout) let finalization = await self.finalizeCapture(interactive: false) if finalization.interactiveStopParticipated { return } do { let outputPath = try finalization.outputResult.get() print("Recording stopped. Output path: \(outputPath)") fflush(stdout) exit(0) } catch { fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) fflush(stderr) exit(1) } return } guard let display = Self.captureDisplay(for: window.frame, from: availableContent.displays) else { continue } let captureRect = window.frame.intersection(display.frame) guard captureRect.width > 0, captureRect.height > 0 else { continue } let cropRect = CGRect( x: (captureRect.minX - display.frame.minX) / display.frame.width, y: (captureRect.minY - display.frame.minY) / display.frame.height, width: captureRect.width / display.frame.width, height: captureRect.height / display.frame.height ) if self.windowCropDisplayId != display.displayID, let activeStream = self.stream { let excludedApplications = availableContent.applications.filter { self.excludedProcessIds.contains($0.processID) } let filter = SCContentFilter( display: display, excludingApplications: excludedApplications, exceptingWindows: [] ) do { try await activeStream.updateContentFilter(filter) } catch { continue } } await withCheckedContinuation { (continuation: CheckedContinuation) in self.queue.async { if self.isRecording { self.windowCropRect = cropRect self.windowCropDisplayId = display.displayID } continuation.resume() } } } } } private static func captureDisplay(for frame: CGRect, from displays: [SCDisplay]) -> SCDisplay? { let midpoint = CGPoint(x: frame.midX, y: frame.midY) return displays.first(where: { $0.frame.contains(midpoint) }) ?? displays.filter { $0.frame.intersects(frame) }.max { $0.frame.intersection(frame).width * $0.frame.intersection(frame).height < $1.frame.intersection(frame).width * $1.frame.intersection(frame).height } } private static func scaleFactor(for displayId: CGDirectDisplayID) -> Int { guard let mode = CGDisplayCopyDisplayMode(displayId) else { return 1 } return max(1, mode.pixelWidth / max(1, mode.width)) } } final class RecorderService { private let recorder = ScreenCaptureRecorder() private let queue = DispatchQueue(label: "recordly.screencapturekit.commands") private let completionGroup = DispatchGroup() private var succeeded = true // Accessed only by serialized command operations. private var captureStarted = false private func enqueue(_ operation: @escaping () async -> Void) { queue.async { let semaphore = DispatchSemaphore(value: 0) Task { await operation() semaphore.signal() } semaphore.wait() } } func start(configJSON: String) { completionGroup.enter() enqueue { do { try await self.recorder.startCapture(configJSON: configJSON) self.captureStarted = true } catch { self.succeeded = false fputs("Error starting capture: \(error.localizedDescription)\n", stderr) fflush(stderr) self.completionGroup.leave() } } } func stop() { enqueue { // Failed startup already releases completionGroup. EOF must not do it again. guard self.captureStarted else { return } self.captureStarted = false do { let outputPath = try await self.recorder.stopCapture() print("Recording stopped. Output path: \(outputPath)") fflush(stdout) self.completionGroup.leave() } catch { self.succeeded = false fputs("Error stopping capture: \(error.localizedDescription)\n", stderr) fflush(stderr) self.completionGroup.leave() } } } func pause() { enqueue { if await self.recorder.pauseCapture() { print("Recording paused") fflush(stdout) } } } func resume() { enqueue { if await self.recorder.resumeCapture() { print("Recording resumed") fflush(stdout) } } } func waitUntilFinished() -> Bool { completionGroup.wait() return succeeded } func updateHudExclusions(_ processIds: [Int32], commandId: String? = nil) { enqueue { do { try await self.recorder.updateHudExclusions(processIds) print("HUD capture filter updated" + (commandId.map { " " + $0 } ?? "")) fflush(stdout) } catch { fputs("Error updating HUD capture filter: \(error.localizedDescription)\n", stderr) fflush(stderr) } } } } guard CommandLine.arguments.count >= 2 else { fputs("Missing config JSON\n", stderr) fflush(stderr) exit(1) } // Force CoreGraphics Services initialization on the main thread. // ScreenCaptureKit still requires CoreGraphics Services to be initialized in a CLI tool. let _ = CGMainDisplayID() // Pre-flight check: ensure screen recording permission is granted before // attempting capture. On macOS 15+, a one-session grant may expire after the // parent app restarts. CGRequestScreenCaptureAccess() will trigger the // system-level permission dialog (or open System Settings) when not yet granted. if !CGPreflightScreenCaptureAccess() { let granted = CGRequestScreenCaptureAccess() if !granted { fputs("SCREEN_RECORDING_PERMISSION_DENIED\n", stderr) fflush(stderr) exit(1) } } // Pre-flight check for microphone access when mic capture is requested. if let configData = CommandLine.arguments[1].data(using: .utf8), let config = try? JSONDecoder().decode(CaptureConfig.self, from: configData), config.capturesMicrophone == true { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: break case .notDetermined: let sem = DispatchSemaphore(value: 0) AVCaptureDevice.requestAccess(for: .audio) { _ in sem.signal() } sem.wait() if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) fflush(stderr) exit(1) } default: fputs("MICROPHONE_PERMISSION_DENIED\n", stderr) fflush(stderr) exit(1) } } let service = RecorderService() service.start(configJSON: CommandLine.arguments[1]) struct HudExclusionCommand: Decodable { let commandId: String let processIds: [Int32] } DispatchQueue.global(qos: .utility).async { while let rawInput = readLine(strippingNewline: true) { let input = rawInput.lowercased() if input.hasPrefix("hud-exclusions "), let data = String(rawInput.dropFirst("hud-exclusions ".count)).data(using: .utf8) { if let command = try? JSONDecoder().decode(HudExclusionCommand.self, from: data), UUID(uuidString: command.commandId) != nil { service.updateHudExclusions(command.processIds, commandId: command.commandId) } else if let processIds = try? JSONDecoder().decode([Int32].self, from: data) { service.updateHudExclusions(processIds) } continue } if input == "pause" { service.pause() continue } if input == "resume" { service.resume() continue } if input == "stop" { break } } // EOF means the Electron parent exited or restarted. Finalize and release // capture devices just as we do for an explicit stop command. service.stop() } if !service.waitUntilFinished() { exit(1) }