2026-07-10 15:33:31 -04:00
|
|
|
import ArgumentParser
|
|
|
|
|
import Foundation
|
|
|
|
|
import ForgeCamera
|
|
|
|
|
import ForgeCameraARRI
|
|
|
|
|
import ForgeGrade
|
|
|
|
|
import ForgeColor
|
|
|
|
|
import ForgeSim
|
2026-07-10 15:57:29 -04:00
|
|
|
import ForgeLibrary
|
|
|
|
|
import ForgeExport
|
2026-07-10 16:16:52 -04:00
|
|
|
import ForgeOffload
|
2026-07-10 16:19:34 -04:00
|
|
|
import ForgeProxy
|
2026-07-10 15:33:31 -04:00
|
|
|
|
|
|
|
|
@main
|
|
|
|
|
struct Forge: AsyncParsableCommand {
|
|
|
|
|
static let configuration = CommandConfiguration(
|
|
|
|
|
commandName: "forge",
|
2026-07-10 16:16:52 -04:00
|
|
|
abstract: "On-set live grading + DIT toolkit: camera look push, clip logging, offload, proxies.",
|
2026-07-11 15:39:08 -04:00
|
|
|
subcommands: [Push.self, Status.self, Sim.self, Export.self, Offload.self, Verify.self, Proxy.self, IngestMatch.self])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct IngestMatch: AsyncParsableCommand {
|
|
|
|
|
static let configuration = CommandConfiguration(
|
|
|
|
|
commandName: "ingest-match",
|
|
|
|
|
abstract: "Match offloaded media to logged shots by clip name; write grade sidecars (.cdl/.cube) beside each clip.")
|
|
|
|
|
|
|
|
|
|
@Argument(help: "Media root (offloaded card / RAID folder)")
|
|
|
|
|
var media: String
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Library database path")
|
|
|
|
|
var db: String
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Day label")
|
|
|
|
|
var day: String
|
|
|
|
|
|
|
|
|
|
@Flag(name: .long, help: "Skip .cdl sidecars")
|
|
|
|
|
var noCdl = false
|
|
|
|
|
|
|
|
|
|
@Flag(name: .long, help: "Skip .cube sidecars")
|
|
|
|
|
var noCube = false
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Cube lattice size")
|
|
|
|
|
var lattice: Int = 33
|
|
|
|
|
|
|
|
|
|
@Flag(name: .long, help: "Overwrite existing sidecars")
|
|
|
|
|
var force = false
|
|
|
|
|
|
|
|
|
|
func run() async throws {
|
|
|
|
|
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 in \(db)") }
|
|
|
|
|
|
|
|
|
|
let result = try IngestMatcher.match(
|
|
|
|
|
mediaRoot: media, store: store, dayID: target.id,
|
|
|
|
|
options: .init(writeCDL: !noCdl, writeCube: !noCube, latticeSize: lattice, force: force))
|
|
|
|
|
|
|
|
|
|
let withSidecars = result.matched.filter(\.sidecarsWritten).count
|
|
|
|
|
print("matched \(result.matched.count) clips (\(withSidecars) sidecars written)")
|
|
|
|
|
if !result.unmatchedMedia.isEmpty {
|
|
|
|
|
print("unmatched media (\(result.unmatchedMedia.count)):")
|
|
|
|
|
for m in result.unmatchedMedia { print(" \(m)") }
|
|
|
|
|
}
|
|
|
|
|
if !result.shotsWithoutMedia.isEmpty {
|
|
|
|
|
print("shots without media (\(result.shotsWithoutMedia.count)):")
|
|
|
|
|
for s in result.shotsWithoutMedia { print(" \(s)") }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-10 16:19:34 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
2026-07-10 16:16:52 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Offload: AsyncParsableCommand {
|
|
|
|
|
static let configuration = CommandConfiguration(abstract: "Verified copy of a card to one or more destinations, with MHL manifest.")
|
|
|
|
|
|
|
|
|
|
@Argument(help: "Source directory (camera card)")
|
|
|
|
|
var source: String
|
|
|
|
|
|
|
|
|
|
@Option(name: [.customLong("to")], help: "Destination directory (repeatable)")
|
|
|
|
|
var destinations: [String]
|
|
|
|
|
|
|
|
|
|
@Flag(name: .long, help: "Re-copy destination files with mismatched size (interrupted copies)")
|
|
|
|
|
var overwritePartial = false
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Write HTML report to this path")
|
|
|
|
|
var report: String?
|
|
|
|
|
|
2026-07-11 15:46:41 -04:00
|
|
|
@Option(name: .long, help: "Checksum: xxh64|xxh3|xxh128|md5|sha1")
|
|
|
|
|
var checksum: String = "xxh64"
|
|
|
|
|
|
2026-07-10 16:16:52 -04:00
|
|
|
func run() async throws {
|
|
|
|
|
guard !destinations.isEmpty else {
|
|
|
|
|
throw ValidationError("at least one --to destination required")
|
|
|
|
|
}
|
2026-07-11 15:46:41 -04:00
|
|
|
guard let algo = HashAlgorithm(rawValue: checksum) else {
|
|
|
|
|
throw ValidationError("unknown checksum '\(checksum)' (xxh64|xxh3|xxh128|md5|sha1)")
|
|
|
|
|
}
|
2026-07-10 16:16:52 -04:00
|
|
|
let engine = CopyEngine()
|
|
|
|
|
let result = try engine.offload(source: source, destinations: destinations, overwritePartial: overwritePartial)
|
|
|
|
|
|
|
|
|
|
// MHL manifest at each destination root.
|
|
|
|
|
let files = result.files.map(\.relativePath)
|
|
|
|
|
for dest in destinations {
|
2026-07-11 15:46:41 -04:00
|
|
|
try MHL.write(
|
|
|
|
|
root: dest, creator: "Forge", files: files,
|
|
|
|
|
to: dest + "/" + ((source as NSString).lastPathComponent) + ".mhl",
|
|
|
|
|
algorithm: algo)
|
2026-07-10 16:16:52 -04:00
|
|
|
}
|
|
|
|
|
if let reportPath = report {
|
|
|
|
|
try OffloadReportWriter.html(result).write(toFile: reportPath, atomically: true, encoding: .utf8)
|
|
|
|
|
}
|
|
|
|
|
let copied = result.files.filter { !$0.skipped }.count
|
|
|
|
|
print("offloaded \(result.files.count) files (\(copied) copied, \(result.files.count - copied) skipped), \(OffloadReportWriter.formatBytes(result.totalBytes)) to \(destinations.count) destination(s) — all verified")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Verify: AsyncParsableCommand {
|
|
|
|
|
static let configuration = CommandConfiguration(abstract: "Verify a tree against an MHL manifest.")
|
|
|
|
|
|
|
|
|
|
@Argument(help: "Path to .mhl manifest")
|
|
|
|
|
var manifest: String
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Tree root (default: manifest's directory)")
|
|
|
|
|
var root: String?
|
|
|
|
|
|
|
|
|
|
func run() async throws {
|
|
|
|
|
let manifestText = try String(contentsOfFile: manifest, encoding: .utf8)
|
|
|
|
|
let treeRoot = root ?? (manifest as NSString).deletingLastPathComponent
|
|
|
|
|
let result = try MHL.verify(manifest: manifestText, root: treeRoot)
|
|
|
|
|
if result.passed {
|
|
|
|
|
print("PASSED: \(result.checked) files verified")
|
|
|
|
|
} else {
|
|
|
|
|
print("FAILED: \(result.failures.count)/\(result.checked) files:")
|
|
|
|
|
for f in result.failures {
|
|
|
|
|
print(" \(f.path): \(f.reason)")
|
|
|
|
|
}
|
|
|
|
|
throw ExitCode(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-10 15:57:29 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Export: AsyncParsableCommand {
|
|
|
|
|
static let configuration = CommandConfiguration(abstract: "Export a day's shots for post.")
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Library database path")
|
|
|
|
|
var db: String
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Day label (e.g. 'Day 01')")
|
|
|
|
|
var day: String
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Format: ale|csv|edl|ccc|cube")
|
|
|
|
|
var format: String
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Output file (or directory for cube)")
|
|
|
|
|
var out: String
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Timebase fps for ALE/EDL")
|
|
|
|
|
var fps: Int = 24
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Cube lattice size")
|
|
|
|
|
var lattice: Int = 33
|
|
|
|
|
|
|
|
|
|
func run() async throws {
|
|
|
|
|
let store = try LibraryStore(path: db)
|
|
|
|
|
// Find day by label across all projects.
|
|
|
|
|
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 in \(db)")
|
|
|
|
|
}
|
|
|
|
|
let shots = try store.listShots(dayID: target.id)
|
|
|
|
|
guard !shots.isEmpty else {
|
|
|
|
|
throw ValidationError("no shots logged for '\(day)'")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func firstCDL(_ s: Shot) -> ForgeColor.CDL? {
|
|
|
|
|
guard let data = s.gradeSnapshot,
|
|
|
|
|
let stack = try? GradeSnapshot.decode(data) else { return nil }
|
|
|
|
|
for n in stack.nodes {
|
|
|
|
|
if case .cdl(let c) = n.kind, n.isEnabled { return c }
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch format {
|
|
|
|
|
case "ale":
|
|
|
|
|
try PostExport.writeALE(shots: shots, fps: fps)
|
|
|
|
|
.write(toFile: out, atomically: true, encoding: .utf8)
|
|
|
|
|
case "csv":
|
|
|
|
|
try PostExport.writeCSV(shots: shots)
|
|
|
|
|
.write(toFile: out, atomically: true, encoding: .utf8)
|
|
|
|
|
case "edl":
|
|
|
|
|
try PostExport.writeEDL(shots: shots, title: day, fps: fps)
|
|
|
|
|
.write(toFile: out, atomically: true, encoding: .utf8)
|
|
|
|
|
case "ccc":
|
|
|
|
|
let entries = shots.compactMap { s in firstCDL(s).map { (s.clipName, $0) } }
|
|
|
|
|
try CDLExport.writeCCC(entries)
|
|
|
|
|
.write(toFile: out, atomically: true, encoding: .utf8)
|
|
|
|
|
case "cube":
|
|
|
|
|
try FileManager.default.createDirectory(atPath: out, withIntermediateDirectories: true)
|
|
|
|
|
var written = 0
|
|
|
|
|
for s in shots {
|
|
|
|
|
guard let snap = s.gradeSnapshot else { continue }
|
|
|
|
|
let cube = try CubeExport.fromSnapshot(snap, latticeSize: lattice, title: s.clipName)
|
|
|
|
|
try cube.write(toFile: "\(out)/\(s.clipName).cube", atomically: true, encoding: .utf8)
|
|
|
|
|
written += 1
|
|
|
|
|
}
|
|
|
|
|
print("wrote \(written) cubes to \(out)/")
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
throw ValidationError("unknown format '\(format)' (ale|csv|edl|ccc|cube)")
|
|
|
|
|
}
|
|
|
|
|
print("wrote \(format): \(out) (\(shots.count) shots)")
|
|
|
|
|
}
|
2026-07-10 15:33:31 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct CameraOptions: ParsableArguments {
|
|
|
|
|
@Option(name: .long, help: "Camera IP/host")
|
|
|
|
|
var host: String
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Camera port")
|
|
|
|
|
var port: Int
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Driver: arri")
|
|
|
|
|
var driver: String = "arri"
|
|
|
|
|
|
|
|
|
|
func makeDriver() throws -> any CameraDriver {
|
|
|
|
|
switch driver {
|
|
|
|
|
case "arri":
|
|
|
|
|
return ArriDriver(host: host, port: port)
|
|
|
|
|
default:
|
|
|
|
|
throw ValidationError("unknown driver '\(driver)' (supported: arri)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Push: AsyncParsableCommand {
|
|
|
|
|
static let configuration = CommandConfiguration(abstract: "Flatten a grade stack and push to camera.")
|
|
|
|
|
|
|
|
|
|
@OptionGroup var camera: CameraOptions
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Grade snapshot JSON file")
|
|
|
|
|
var look: String
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "LUT lattice size")
|
|
|
|
|
var lattice: Int = 33
|
|
|
|
|
|
|
|
|
|
@Flag(name: .long, help: "Keep leading CDL native (camera trim), bake remainder")
|
|
|
|
|
var splitCdl = false
|
|
|
|
|
|
|
|
|
|
func run() async throws {
|
|
|
|
|
let data = try Data(contentsOf: URL(fileURLWithPath: look))
|
|
|
|
|
let stack = try GradeSnapshot.decode(data)
|
|
|
|
|
let driver = try camera.makeDriver()
|
|
|
|
|
|
|
|
|
|
let flattened = Flattener.flatten(
|
|
|
|
|
stack: stack,
|
|
|
|
|
latticeSize: lattice,
|
|
|
|
|
splitLeadingCDL: splitCdl && driver.capabilities.supportsNativeCDL)
|
|
|
|
|
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
try await driver.push(look: flattened)
|
|
|
|
|
await driver.disconnect()
|
|
|
|
|
print("pushed: lattice=\(lattice) cdl=\(flattened.cdl != nil ? "native" : "baked") -> \(camera.host):\(camera.port)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Status: AsyncParsableCommand {
|
|
|
|
|
static let configuration = CommandConfiguration(abstract: "Print camera connection/rec state and metadata.")
|
|
|
|
|
|
|
|
|
|
@OptionGroup var camera: CameraOptions
|
|
|
|
|
|
|
|
|
|
func run() async throws {
|
|
|
|
|
let driver = try camera.makeDriver()
|
|
|
|
|
var iterator = driver.state.makeAsyncIterator()
|
|
|
|
|
try await driver.connect()
|
|
|
|
|
|
|
|
|
|
// First polled state carries metadata.
|
|
|
|
|
var reported = false
|
|
|
|
|
for _ in 0..<10 {
|
|
|
|
|
guard let s = await iterator.next() else { break }
|
|
|
|
|
if let md = s.metadata {
|
|
|
|
|
print("connection: \(s.connection.rawValue)")
|
|
|
|
|
print("recording: \(s.isRecording)")
|
|
|
|
|
print("clip: \(s.clipName ?? "-")")
|
|
|
|
|
print("ei: \(md.exposureIndex.map(String.init) ?? "-") wb: \(md.whiteBalance.map(String.init) ?? "-") tint: \(md.tint.map(String.init) ?? "-")")
|
|
|
|
|
print("fps: \(md.fps.map { String($0) } ?? "-") tc: \(md.timecode ?? "-")")
|
|
|
|
|
reported = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !reported {
|
|
|
|
|
print("connection: connected (no metadata yet)")
|
|
|
|
|
}
|
|
|
|
|
await driver.disconnect()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct Sim: AsyncParsableCommand {
|
|
|
|
|
static let configuration = CommandConfiguration(abstract: "Run an ARRI-style camera simulator.")
|
|
|
|
|
|
|
|
|
|
@Option(name: .long, help: "Listen port")
|
|
|
|
|
var port: Int = 8035
|
|
|
|
|
|
|
|
|
|
func run() async throws {
|
|
|
|
|
let sim = ArriSimulator()
|
|
|
|
|
try await sim.start(port: port)
|
|
|
|
|
let bound = await sim.boundPort ?? port
|
|
|
|
|
print("arri sim listening on 127.0.0.1:\(bound)")
|
|
|
|
|
// Run until killed.
|
|
|
|
|
while true {
|
|
|
|
|
try await Task.sleep(for: .seconds(3600))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|