mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-29 17:25:54 +00:00
373 lines
14 KiB
Swift
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))
|
|
}
|
|
} |