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

197 lines
6.7 KiB
Swift

import ArgumentParser
import Foundation
import ForgeCamera
import ForgeCameraARRI
import ForgeGrade
import ForgeColor
import ForgeSim
import ForgeLibrary
import ForgeExport
@main
struct Forge: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "forge",
abstract: "On-set live grading: push looks to cameras over IP.",
subcommands: [Push.self, Status.self, Sim.self, Export.self])
}
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))
}
}
}