proxy: ffmpeg command builder (ProRes/DNxHR/H264, scale, lut3d grade, drawtext burn-in), process runner, graded ProxyJob w/ temp cube lifecycle — 10 tests
This commit is contained in:
parent
297477f342
commit
b7d43785cf
4 changed files with 357 additions and 6 deletions
|
|
@ -1 +0,0 @@
|
|||
// ForgeProxy
|
||||
177
Sources/ForgeProxy/Proxy.swift
Normal file
177
Sources/ForgeProxy/Proxy.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import XCTest
|
||||
|
||||
final class ForgeProxyPlaceholderTests: XCTestCase {
|
||||
func testPlaceholder() { XCTAssertTrue(true) }
|
||||
}
|
||||
180
Tests/ForgeProxyTests/ProxyTests.swift
Normal file
180
Tests/ForgeProxyTests/ProxyTests.swift
Normal file
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue