From 92a29d5a604c6ce015ce376cdff307bd46c37bd2 Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 20:19:34 +0000 Subject: [PATCH] =?UTF-8?q?cli:=20forge=20proxy=20=E2=80=94=20per-shot=20p?= =?UTF-8?q?roxy=20render=20from=20library,=20source=20matching,=20grade=20?= =?UTF-8?q?bake=20+=20text=20burn=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/forge-cli/Main.swift | 83 +++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/Sources/forge-cli/Main.swift b/Sources/forge-cli/Main.swift index f1dc084..5b57b8f 100644 --- a/Sources/forge-cli/Main.swift +++ b/Sources/forge-cli/Main.swift @@ -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 {