rainbow-dragon/Sources/forge-cli/Main.swift

394 lines
14 KiB
Swift
Raw Normal View History

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])
}
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?
func run() async throws {
guard !destinations.isEmpty else {
throw ValidationError("at least one --to destination required")
}
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")
}
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))
}
}
}