178 lines
5.6 KiB
Swift
178 lines
5.6 KiB
Swift
|
|
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)
|
||
|
|
}
|
||
|
|
}
|