cli: forge proxy — per-shot proxy render from library, source matching, grade bake + text burn flags

This commit is contained in:
Forge Dev 2026-07-10 20:19:34 +00:00
parent b7d43785cf
commit 92a29d5a60

View file

@ -8,13 +8,94 @@ import ForgeSim
import ForgeLibrary
import ForgeExport
import ForgeOffload
import ForgeProxy
@main
struct Forge: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "forge",
abstract: "On-set live grading + DIT toolkit: camera look push, clip logging, offload, proxies.",
subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self])
subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self, Proxy.self])
}
struct Proxy: AsyncParsableCommand {
static let configuration = CommandConfiguration(abstract: "Render proxies for a day's shots, optionally with grade + burn-ins.")
@Option(name: .long, help: "Library database path")
var db: String
@Option(name: .long, help: "Day label")
var day: String
@Option(name: .long, help: "Directory containing source clips (matched by clip name)")
var media: String
@Option(name: .long, help: "Output directory")
var out: String
@Option(name: .long, help: "Preset: prores-proxy|dnxhr-lb|h264")
var preset: String = "h264"
@Option(name: .long, help: "Scale: full|half|quarter")
var scale: String = "half"
@Flag(name: .long, help: "Bake shot grade into proxy")
var burnLook = false
@Flag(name: .long, help: "Burn clip name + timecode overlay")
var burnText = false
@Option(name: .long, help: "ffmpeg binary path")
var ffmpeg: String = "/usr/bin/ffmpeg"
func run() async throws {
guard let presetVal = ProxyPreset(rawValue: preset) else {
throw ValidationError("unknown preset '\(preset)'")
}
guard let scaleVal = ProxyScale(rawValue: scale) else {
throw ValidationError("unknown scale '\(scale)'")
}
let store = try LibraryStore(path: db)
var target: ForgeLibrary.Day?
for p in try store.listProjects() {
if let d = try store.listDays(projectID: p.id).first(where: { $0.label == day }) {
target = d
break
}
}
guard let target else { throw ValidationError("day '\(day)' not found") }
let shots = try store.listShots(dayID: target.id)
try FileManager.default.createDirectory(atPath: out, withIntermediateDirectories: true)
var rendered = 0
var missing = 0
for shot in shots {
// Find source by clip name with common extensions.
let candidates = ["mov", "mxf", "mp4", "braw", "arriraw"].map {
"\(media)/\(shot.clipName).\($0)"
}
guard let input = candidates.first(where: { FileManager.default.fileExists(atPath: $0) }) else {
print("skip \(shot.clipName): no source in \(media)")
missing += 1
continue
}
let ext = presetVal == .h264 ? "mp4" : (presetVal == .dnxhrLB ? "mxf" : "mov")
let job = ProxyJob(
input: input,
output: "\(out)/\(shot.clipName).\(ext)",
preset: presetVal,
scale: scaleVal,
gradeSnapshot: burnLook ? shot.gradeSnapshot : nil,
burnIn: burnText ? BurnIn(
clipName: shot.clipName,
timecode: shot.timecodeIn ?? "00:00:00:00",
fps: Int(shot.fps ?? 24)) : nil)
try await job.run(ffmpegPath: ffmpeg)
print("rendered \(shot.clipName)")
rendered += 1
}
print("proxies: \(rendered) rendered, \(missing) missing sources")
}
}
struct Offload: AsyncParsableCommand {