diff --git a/Sources/ForgeProxy/ForgeProxy.swift b/Sources/ForgeProxy/ForgeProxy.swift deleted file mode 100644 index b926314..0000000 --- a/Sources/ForgeProxy/ForgeProxy.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeProxy diff --git a/Sources/ForgeProxy/Proxy.swift b/Sources/ForgeProxy/Proxy.swift new file mode 100644 index 0000000..0638312 --- /dev/null +++ b/Sources/ForgeProxy/Proxy.swift @@ -0,0 +1,177 @@ +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) + } +} diff --git a/Tests/ForgeProxyTests/PlaceholderTests.swift b/Tests/ForgeProxyTests/PlaceholderTests.swift deleted file mode 100644 index 1f956d1..0000000 --- a/Tests/ForgeProxyTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeProxyPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -} diff --git a/Tests/ForgeProxyTests/ProxyTests.swift b/Tests/ForgeProxyTests/ProxyTests.swift new file mode 100644 index 0000000..08c3377 --- /dev/null +++ b/Tests/ForgeProxyTests/ProxyTests.swift @@ -0,0 +1,180 @@ +import XCTest +import Foundation +import ForgeColor +import ForgeGrade +@testable import ForgeProxy + +final class FFmpegCommandTests: XCTestCase { + + // ProRes proxy preset argv golden. + func testProResProxyArgs() { + let cmd = FFmpegCommandBuilder.build( + input: "/media/A001C001.mov", + output: "/proxies/A001C001.mov", + preset: .proresProxy, + scale: .half, + lutPath: nil, + burnIn: nil) + XCTAssertEqual(cmd.first, "-y") + XCTAssertTrue(cmd.contains("-i")) + XCTAssertTrue(cmd.contains("/media/A001C001.mov")) + XCTAssertTrue(cmd.contains("prores_ks")) + // Profile 0 = proxy. + let profileIdx = cmd.firstIndex(of: "-profile:v")! + XCTAssertEqual(cmd[profileIdx + 1], "0") + XCTAssertTrue(cmd.contains("scale=iw/2:ih/2")) + XCTAssertEqual(cmd.last, "/proxies/A001C001.mov") + } + + // H264 preset. + func testH264Args() { + let cmd = FFmpegCommandBuilder.build( + input: "in.mov", output: "out.mp4", + preset: .h264, scale: .quarter, lutPath: nil, burnIn: nil) + XCTAssertTrue(cmd.contains("libx264")) + XCTAssertTrue(cmd.contains("scale=iw/4:ih/4")) + } + + // DNxHR preset. + func testDNxHRArgs() { + let cmd = FFmpegCommandBuilder.build( + input: "in.mov", output: "out.mxf", + preset: .dnxhrLB, scale: .full, lutPath: nil, burnIn: nil) + XCTAssertTrue(cmd.contains("dnxhd")) + XCTAssertTrue(cmd.contains("dnxhr_lb")) + // Full scale: no scale filter unless LUT/burn present. + XCTAssertFalse(cmd.joined(separator: " ").contains("scale=")) + } + + // LUT filter: lut3d with escaped path joins filter chain before scale. + func testLutFilter() { + let cmd = FFmpegCommandBuilder.build( + input: "in.mov", output: "out.mov", + preset: .proresProxy, scale: .half, + lutPath: "/tmp/my look's.cube", burnIn: nil) + let vf = cmd[cmd.firstIndex(of: "-vf")! + 1] + XCTAssertTrue(vf.contains("lut3d=")) + // ffmpeg filter escaping: ' -> '\'' inside quoted value, or backslash escapes. + XCTAssertTrue(vf.contains("my look")) + XCTAssertTrue(vf.contains("lut3d="), "lut3d must precede scale") + XCTAssertLessThan(vf.range(of: "lut3d=")!.lowerBound, vf.range(of: "scale=")!.lowerBound) + } + + // Burn-in: drawtext with clip name + timecode. + func testBurnIn() { + let cmd = FFmpegCommandBuilder.build( + input: "in.mov", output: "out.mov", + preset: .h264, scale: .half, + lutPath: nil, + burnIn: BurnIn(clipName: "A001C001", timecode: "10:00:00:00", fps: 24)) + let vf = cmd[cmd.firstIndex(of: "-vf")! + 1] + XCTAssertTrue(vf.contains("drawtext")) + XCTAssertTrue(vf.contains("A001C001")) + XCTAssertTrue(vf.contains("timecode")) + // TC colons escaped for filter syntax. + XCTAssertTrue(vf.contains("10\\:00\\:00\\:00")) + } + + // Audio: copy through. + func testAudioCopied() { + let cmd = FFmpegCommandBuilder.build( + input: "in.mov", output: "out.mov", + preset: .proresProxy, scale: .full, lutPath: nil, burnIn: nil) + let aIdx = cmd.firstIndex(of: "-c:a")! + XCTAssertEqual(cmd[aIdx + 1], "copy") + } +} + +final class ProxyJobTests: XCTestCase { + + // Runner invokes executable with built argv; fake ffmpeg script records args. + func testRunnerInvokesWithArgs() async throws { + let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + + let fake = dir + "/fake-ffmpeg" + try #""" + #!/bin/bash + echo "$@" > "$(dirname "$0")/argv.txt" + exit 0 + """#.write(toFile: fake, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake) + + let runner = ProxyRunner(ffmpegPath: fake) + try await runner.run(args: ["-y", "-i", "in.mov", "out.mov"]) + + let argv = try String(contentsOfFile: dir + "/argv.txt", encoding: .utf8) + XCTAssertEqual(argv.trimmingCharacters(in: .whitespacesAndNewlines), "-y -i in.mov out.mov") + } + + // Nonzero exit throws with stderr. + func testRunnerThrowsOnFailure() async throws { + let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let fake = dir + "/fake-ffmpeg" + try "#!/bin/bash\necho 'boom' >&2\nexit 1\n".write(toFile: fake, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake) + + let runner = ProxyRunner(ffmpegPath: fake) + do { + try await runner.run(args: ["x"]) + XCTFail("expected throw") + } catch let e as ProxyError { + guard case .ffmpegFailed(_, let stderr) = e else { return XCTFail("wrong error") } + XCTAssertTrue(stderr.contains("boom")) + } + } + + // Graded job: snapshot -> temp .cube written -> lut3d arg references it -> cleaned up after. + func testGradedJobWritesCube() async throws { + let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let fake = dir + "/fake-ffmpeg" + try #""" + #!/bin/bash + echo "$@" > "$(dirname "$0")/argv.txt" + # capture cube existence at run time + if [[ "$@" == *lut3d* ]]; then + cube=$(echo "$@" | grep -oE "[^ =']*\.cube" | head -1) + [[ -f "$cube" ]] && echo "cube-exists" > "$(dirname "$0")/cube.txt" + fi + exit 0 + """#.write(toFile: fake, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake) + + let stack = GradeStack(nodes: [GradeNode(kind: .cdl(CDL( + slope: SIMD3(1.2, 1, 1), offset: .zero, power: .one, saturation: 1)))]) + let snapshot = try GradeSnapshot.encode(stack) + + let job = ProxyJob( + input: "in.mov", + output: dir + "/out.mov", + preset: .proresProxy, + scale: .half, + gradeSnapshot: snapshot, + burnIn: nil) + try await job.run(ffmpegPath: fake, latticeSize: 9) + + let argv = try String(contentsOfFile: dir + "/argv.txt", encoding: .utf8) + XCTAssertTrue(argv.contains("lut3d=")) + XCTAssertEqual(try String(contentsOfFile: dir + "/cube.txt", encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines), "cube-exists") + } + + // Ungraded fallback: no snapshot -> no lut3d in args. + func testUngradedNoLut() async throws { + let dir = NSTemporaryDirectory() + "forge-proxy-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: dir) } + let fake = dir + "/fake-ffmpeg" + try "#!/bin/bash\necho \"$@\" > \"$(dirname \"$0\")/argv.txt\"\nexit 0\n".write(toFile: fake, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fake) + + let job = ProxyJob(input: "in.mov", output: dir + "/out.mov", preset: .h264, scale: .half, gradeSnapshot: nil, burnIn: nil) + try await job.run(ffmpegPath: fake, latticeSize: 9) + let argv = try String(contentsOfFile: dir + "/argv.txt", encoding: .utf8) + XCTAssertFalse(argv.contains("lut3d")) + } +}