import Foundation #if os(macOS) /// Thread-safe progress state final class ProgressState: @unchecked Sendable { private var lastTime: String = "" private let lock = NSLock() func update(_ timeStr: String) -> Bool { lock.lock() defer { lock.unlock() } guard timeStr != lastTime else { return false } lastTime = timeStr return true } } public final class FFmpegRecorder: StreamRecorderEngine { public weak var delegate: StreamRecorderDelegate? public private(set) var isRecording = false private var process: Process? private var duration: Double = 0 private var stopRequested = false public init() {} public func start(url: URL, outputPath: String, duration: Double) async throws { guard !isRecording else { return } self.duration = duration // 查找 ffmpeg let ffmpegPath = findFFmpeg() guard FileManager.default.fileExists(atPath: ffmpegPath) else { throw RecordingError.noFFmpeg } // 解析 HLS 变体 let resolvedURL = HLSVariantResolver.resolve(url: url, maxWidth: 1280) ?? url // 删除已存在的输出文件 if FileManager.default.fileExists(atPath: outputPath) { try? FileManager.default.removeItem(atPath: outputPath) } // 启动 ffmpeg 进程 let proc = Process() proc.executableURL = URL(fileURLWithPath: ffmpegPath) var args = [ "-y", "-i", resolvedURL.absoluteString, ] // duration=0 表示无限录制,不传 -t 参数 if duration > 0 { args += ["-t", String(duration)] } args += [ "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-movflags", "+frag_keyframe+empty_moov+default_base_moof", outputPath ] proc.arguments = args let stderrPipe = Pipe() proc.standardError = stderrPipe proc.standardOutput = FileHandle.nullDevice // 解析进度 let progressState = ProgressState() stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in guard let self = self else { return } let data = handle.availableData guard !data.isEmpty else { return } let str = String(data: data, encoding: .utf8) ?? "" if let timeRange = str.range(of: "time=") { let sub = String(str[timeRange.upperBound...]) if let endRange = sub.range(of: " ") ?? sub.range(of: "\n") ?? sub.range(of: "\r") { let timeStr = String(sub[..) in proc.terminationHandler = { _ in continuation.resume() } // Race condition: process may have exited between isRunning check and setting handler if !proc.isRunning { proc.terminationHandler = nil continuation.resume() } } } private func findFFmpeg() -> String { let paths = [ "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg" ] return paths.first { FileManager.default.fileExists(atPath: $0) } ?? "ffmpeg" } private func parseTime(_ str: String) -> Double? { let parts = str.split(separator: ":") guard parts.count == 3, let h = Double(parts[0]), let m = Double(parts[1]), let s = Double(parts[2]) else { return nil } return h * 3600 + m * 60 + s } private func analyzeOutput(path: String, ffprobePath: String) async throws -> RecordingResult { guard FileManager.default.fileExists(atPath: ffprobePath) else { let attrs = try? FileManager.default.attributesOfItem(atPath: path) let fileSize = attrs?[.size] as? Int64 ?? 0 return RecordingResult( outputPath: path, duration: duration, fileSize: fileSize, hasVideo: true, hasAudio: true, videoInfo: nil, audioInfo: nil, audioLevel: nil ) } let proc = Process() proc.executableURL = URL(fileURLWithPath: ffprobePath) proc.arguments = [ "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path ] let pipe = Pipe() proc.standardOutput = pipe proc.standardError = FileHandle.nullDevice do { try proc.run() await waitForProcess(proc) } catch { throw RecordingError.unknown("无法分析输出文件") } let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw RecordingError.unknown("无法解析文件信息") } let format = json["format"] as? [String: Any] let fileDuration = Double(format?["duration"] as? String ?? "0") ?? duration let fileSize = Int64(format?["size"] as? String ?? "0") ?? 0 let streams = json["streams"] as? [[String: Any]] ?? [] var hasVideo = false var hasAudio = false var videoInfo: VideoInfo? var audioInfo: AudioInfo? for stream in streams { let codecType = stream["codec_type"] as? String ?? "" if codecType == "video" { hasVideo = true videoInfo = VideoInfo( codec: stream["codec_name"] as? String ?? "unknown", width: stream["width"] as? Int ?? 0, height: stream["height"] as? Int ?? 0, fps: parseFPS(stream["r_frame_rate"] as? String), pixelFormat: stream["pix_fmt"] as? String ?? "unknown" ) } else if codecType == "audio" { hasAudio = true audioInfo = AudioInfo( codec: stream["codec_name"] as? String ?? "unknown", sampleRate: Int(stream["sample_rate"] as? String ?? "0") ?? 0, channels: stream["channels"] as? Int ?? 0 ) } } let audioLevel = await checkAudioLevel(path: path) return RecordingResult( outputPath: path, duration: fileDuration, fileSize: fileSize, hasVideo: hasVideo, hasAudio: hasAudio, videoInfo: videoInfo, audioInfo: audioInfo, audioLevel: audioLevel ) } private func parseFPS(_ str: String?) -> Double { guard let str = str else { return 30.0 } let parts = str.split(separator: "/") guard parts.count == 2, let num = Double(parts[0]), let den = Double(parts[1]), den > 0 else { return 30.0 } return num / den } private func checkAudioLevel(path: String) async -> AudioLevel? { let ffmpegPath = findFFmpeg() guard FileManager.default.fileExists(atPath: ffmpegPath) else { return nil } let proc = Process() proc.executableURL = URL(fileURLWithPath: ffmpegPath) proc.arguments = [ "-i", path, "-af", "volumedetect", "-f", "null", "-" ] proc.standardOutput = FileHandle.nullDevice let pipe = Pipe() proc.standardError = pipe do { try proc.run() await waitForProcess(proc) } catch { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() let str = String(data: data, encoding: .utf8) ?? "" var meanVolume: Double? var maxVolume: Double? if let meanRange = str.range(of: "mean_volume:") { let sub = String(str[meanRange.upperBound...]) if let dbRange = sub.range(of: " dB") { let volStr = String(sub[..