diff --git a/Sources/forge-cli/Main.swift b/Sources/forge-cli/Main.swift index ecf35ad..4a67e04 100644 --- a/Sources/forge-cli/Main.swift +++ b/Sources/forge-cli/Main.swift @@ -1 +1,115 @@ -@main struct ForgeCLI { static func main() { print("forge") } } +import ArgumentParser +import Foundation +import ForgeCamera +import ForgeCameraARRI +import ForgeGrade +import ForgeColor +import ForgeSim + +@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]) +} + +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)) + } + } +}