import Foundation import ForgeColor import ForgeGrade public enum ProxyError: Error { case ffmpegFailed(code: Int32, stderr: String) case ffmpegNotFound(String) case badSnapshot(String) } /// Codec presets. public enum ProxyPreset: String, Sendable, CaseIterable { case proresProxy = "prores-proxy" case dnxhrLB = "dnxhr-lb" case h264 = "h264" var codecArgs: [String] { switch self { case .proresProxy: return ["-c:v", "prores_ks", "-profile:v", "0"] case .dnxhrLB: return ["-c:v", "dnxhd", "-profile:v", "dnxhr_lb"] case .h264: return ["-c:v", "libx264", "-preset", "fast", "-crf", "20"] } } } public enum ProxyScale: String, Sendable { case full case half case quarter var filter: String? { switch self { case .full: return nil case .half: return "scale=iw/2:ih/2" case .quarter: return "scale=iw/4:ih/4" } } } public struct BurnIn: Sendable { public let clipName: String public let timecode: String public let fps: Int public init(clipName: String, timecode: String, fps: Int) { self.clipName = clipName self.timecode = timecode self.fps = fps } } /// Assembles ffmpeg argv. Filter order: lut3d (grade) -> scale -> drawtext (burn). public enum FFmpegCommandBuilder { /// Escape a path for use inside an ffmpeg filter option value. static func filterEscape(_ path: String) -> String { path.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "'", with: "\\'") .replacingOccurrences(of: ":", with: "\\:") } public static func build( input: String, output: String, preset: ProxyPreset, scale: ProxyScale, lutPath: String?, burnIn: BurnIn? ) -> [String] { var args = ["-y", "-i", input] var filters = [String]() if let lut = lutPath { filters.append("lut3d='\(filterEscape(lut))'") } if let s = scale.filter { filters.append(s) } if let burn = burnIn { let tcEscaped = burn.timecode.replacingOccurrences(of: ":", with: "\\:") filters.append( "drawtext=text='\(burn.clipName)':x=20:y=20:fontsize=24:fontcolor=white:box=1:boxcolor=black@0.5") filters.append( "drawtext=timecode='\(tcEscaped)':rate=\(burn.fps):x=20:y=h-40:fontsize=24:fontcolor=white:box=1:boxcolor=black@0.5") } if !filters.isEmpty { args += ["-vf", filters.joined(separator: ",")] } args += preset.codecArgs args += ["-c:a", "copy"] args.append(output) return args } } /// Runs ffmpeg as subprocess. public struct ProxyRunner: Sendable { public let ffmpegPath: String public init(ffmpegPath: String = "/usr/bin/ffmpeg") { self.ffmpegPath = ffmpegPath } public func run(args: [String]) async throws { guard FileManager.default.isExecutableFile(atPath: ffmpegPath) else { throw ProxyError.ffmpegNotFound(ffmpegPath) } let process = Process() process.executableURL = URL(fileURLWithPath: ffmpegPath) process.arguments = args let stderrPipe = Pipe() process.standardError = stderrPipe process.standardOutput = Pipe() try process.run() // Read stderr concurrently to avoid pipe-buffer deadlock on long output. let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard process.terminationStatus == 0 else { throw ProxyError.ffmpegFailed( code: process.terminationStatus, stderr: String(data: stderrData, encoding: .utf8) ?? "") } } } /// One proxy render: optional grade snapshot baked to temp .cube, burned via lut3d. public struct ProxyJob: Sendable { public let input: String public let output: String public let preset: ProxyPreset public let scale: ProxyScale public let gradeSnapshot: Data? public let burnIn: BurnIn? public init(input: String, output: String, preset: ProxyPreset, scale: ProxyScale, gradeSnapshot: Data?, burnIn: BurnIn?) { self.input = input self.output = output self.preset = preset self.scale = scale self.gradeSnapshot = gradeSnapshot self.burnIn = burnIn } public func run(ffmpegPath: String, latticeSize: Int = 33) async throws { var lutPath: String? if let snapshot = gradeSnapshot { let stack: GradeStack do { stack = try GradeSnapshot.decode(snapshot) } catch { throw ProxyError.badSnapshot("\(error)") } let look = Flattener.flatten(stack: stack, latticeSize: latticeSize, splitLeadingCDL: false) if let lut = look.lut { let path = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString).cube" try CubeFile.write(lut, title: "forge proxy grade") .write(toFile: path, atomically: true, encoding: .utf8) lutPath = path } } defer { if let p = lutPath { try? FileManager.default.removeItem(atPath: p) } } let args = FFmpegCommandBuilder.build( input: input, output: output, preset: preset, scale: scale, lutPath: lutPath, burnIn: burnIn) try await ProxyRunner(ffmpegPath: ffmpegPath).run(args: args) } }