Files
Recordly/electron/native/ScreenCaptureKitRecorder/ScreenCaptureRecorder.swift
T

373 lines
14 KiB
Swift

import Foundation
import ScreenCaptureKit
import AVFoundation
import CoreGraphics
struct CaptureConfig: Codable {
let fps: Int?
let displayId: CGDirectDisplayID?
let windowId: UInt32?
let outputPath: String?
let capturesSystemAudio: Bool?
let capturesMicrophone: Bool?
let systemAudioOutputPath: String?
let microphoneDeviceId: String?
let microphoneLabel: String?
let microphoneOutputPath: String?
}
let targetCaptureFPS = 60
let maxInlineAudioTailExtension = CMTime(seconds: 2.0, preferredTimescale: 600)
final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
private let queue = DispatchQueue(label: "recordly.screencapturekit.video")
var assetWriter: AVAssetWriter?
var videoInput: AVAssetWriterInput?
var systemAudioWriter: AVAssetWriter?
var systemAudioInput: AVAssetWriterInput?
var microphoneOnlyWriter: AVAssetWriter?
var microphoneOnlyInput: AVAssetWriterInput?
var stream: SCStream?
var firstSampleTime: CMTime = .zero
var firstSystemAudioSampleTime: CMTime?
var firstMicrophoneSampleTime: CMTime?
var lastSampleBuffer: CMSampleBuffer?
var lastVideoPresentationTime: CMTime = .zero
var lastVideoDuration: CMTime = .zero
var lastInlineAudioPresentationTime: CMTime = .invalid
var lastInlineAudioDuration: CMTime = .zero
var isRecording = false
var isPaused = false
var pauseStartedHostTime: CMTime?
var pendingResumeAdjustment = false
var accumulatedPausedDuration: CMTime = .zero
var sessionStarted = false
var frameCount = 0
var outputURL: URL?
var microphoneOutputURL: URL?
var trackedWindowId: UInt32?
var windowValidationTask: Task<Void, Never>?
var inlineAudioInput: AVAssetWriterInput?
var firstInlineAudioSampleTime: CMTime?
var capturesSystemAudio = false
var capturesMicrophone = false
var writesSystemAudioToSeparateTrack = false
var writesMicrophoneToSeparateTrack = false
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
}
writesSystemAudioToSeparateTrack = capturesSystemAudio
writesMicrophoneToSeparateTrack = capturesSystemAudio && capturesMicrophone
if capturesMicrophone && !capturesSystemAudio {
writesMicrophoneToSeparateTrack = true
}
let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS)
streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS))
streamConfig.queueDepth = 6
streamConfig.pixelFormat = kCVPixelFormatType_32BGRA
streamConfig.showsCursor = false
streamConfig.capturesAudio = capturesSystemAudio || capturesMicrophone
streamConfig.sampleRate = 48000
streamConfig.channelCount = 2
streamConfig.excludesCurrentProcessAudio = true
if capturesMicrophone {
streamConfig.setValue(true, forKey: "captureMicrophone")
if let microphoneDeviceId = Self.resolveMicrophoneCaptureDeviceID(config: config) {
streamConfig.setValue(microphoneDeviceId, forKey: "microphoneCaptureDeviceID")
}
}
let filter: SCContentFilter
let outputWidth: Int
let outputHeight: Int
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"])
}
filter = SCContentFilter(desktopIndependentWindow: window)
let candidateDisplay = availableContent.displays.first(where: {
$0.frame.intersects(window.frame) || $0.frame.contains(CGPoint(x: window.frame.midX, y: window.frame.midY))
})
let scaleFactor = ScreenCaptureRecorder.scaleFactor(for: candidateDisplay?.displayID ?? CGMainDisplayID())
outputWidth = max(2, Int(window.frame.width) * scaleFactor)
outputHeight = max(2, Int(window.frame.height) * scaleFactor)
if #available(macOS 14.0, *) {
streamConfig.ignoreShadowsSingleWindow = true
}
streamConfig.width = outputWidth
streamConfig.height = outputHeight
} else {
trackedWindowId = 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: [], exceptingWindows: [])
let displayBounds = CGDisplayBounds(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
guard let assistant = AVOutputSettingsAssistant(preset: .preset3840x2160) else {
throw NSError(domain: "RecordlyCapture", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unable to create output settings assistant"])
}
assistant.sourceVideoFormat = try CMVideoFormatDescription(
videoCodecType: .h264,
width: outputWidth,
height: outputHeight
)
guard var outputSettings = assistant.videoSettings else {
throw NSError(domain: "RecordlyCapture", code: 6, userInfo: [NSLocalizedDescriptionKey: "Output settings unavailable"])
}
outputSettings[AVVideoWidthKey] = outputWidth
outputSettings[AVVideoHeightKey] = outputHeight
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings)
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
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: queue)
}
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: queue)
}
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()
print("Recording started")
fflush(stdout)
}
func stopCapture() async throws -> String {
guard isRecording else {
throw NSError(domain: "RecordlyCapture", code: 9, userInfo: [NSLocalizedDescriptionKey: "No recording in progress"])
}
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
}
private static func audioOutputSettings(bitRate: Int) -> [String: Any] {
[
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 48_000,
AVNumberOfChannelsKey: 2,
AVEncoderBitRateKey: bitRate,
]
}
private static func resolveMicrophoneCaptureDeviceID(config: CaptureConfig) -> String? {
let audioDevices = AVCaptureDevice.devices(for: .audio)
if let microphoneLabel = config.microphoneLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneLabel.isEmpty {
if let matchedDevice = audioDevices.first(where: { $0.localizedName == microphoneLabel }) {
return matchedDevice.uniqueID
}
}
if let microphoneDeviceId = config.microphoneDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines), !microphoneDeviceId.isEmpty {
if audioDevices.contains(where: { $0.uniqueID == microphoneDeviceId }) {
return microphoneDeviceId
}
}
return nil
}
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
}
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 }
do {
let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
let windowStillAvailable = availableContent.windows.contains(where: { $0.windowID == trackedWindowId })
if !windowStillAvailable {
print("WINDOW_UNAVAILABLE")
fflush(stdout)
let outputPath = try await self.finishCapture()
print("Recording stopped. Output path: \(outputPath)")
fflush(stdout)
exit(0)
}
} catch {
continue
}
}
}
}
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))
}
}