import ArgumentParser import Foundation import ForgeCamera import ForgeCameraARRI import ForgeGrade import ForgeColor 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, Proxy.self, IngestMatch.self, Report.self]) } struct Report: AsyncParsableCommand { static let configuration = CommandConfiguration(abstract: "Generate an HTML shot report for a day (stills + grades + metadata).") @Option(name: .long, help: "Library database path") var db: String @Option(name: .long, help: "Day label") var day: String @Option(name: .long, help: "Project name for header (default: project record name)") var project: String? @Option(name: .long, help: "Output HTML file") var out: String func run() async throws { let store = try LibraryStore(path: db) var target: ForgeLibrary.Day? var projectName = project ?? "Project" for p in try store.listProjects() { if let d = try store.listDays(projectID: p.id).first(where: { $0.label == day }) { target = d if project == nil { projectName = p.name } 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)'") } let html = ShotReport.html( shots: shots, projectName: projectName, dayLabel: target.label, date: target.date) try html.write(toFile: out, atomically: true, encoding: .utf8) print("wrote report: \(out) (\(shots.count) shots)") } } 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)") } } } } 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 { 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? @Option(name: .long, help: "Checksum: xxh64|xxh3|xxh128|md5|sha1") var checksum: String = "xxh64" func run() async throws { guard !destinations.isEmpty else { throw ValidationError("at least one --to destination required") } guard let algo = HashAlgorithm(rawValue: checksum) else { throw ValidationError("unknown checksum '\(checksum)' (xxh64|xxh3|xxh128|md5|sha1)") } 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 { try MHL.write( root: dest, creator: "Forge", files: files, to: dest + "/" + ((source as NSString).lastPathComponent) + ".mhl", algorithm: algo) } 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) } } } 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)") } } 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)) } } }