2026-07-10 16:03:51 -04:00
|
|
|
import Foundation
|
|
|
|
|
import ForgeGrade
|
|
|
|
|
import ForgeColor
|
|
|
|
|
|
|
|
|
|
public enum GangError: Error {
|
|
|
|
|
/// Some cameras failed; successes already pushed (best-effort).
|
|
|
|
|
case partialFailure([(slot: String, error: Error)])
|
|
|
|
|
case slotNotRegistered(String)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Multicam grade linking. Slots hold grades; linked slots share edits.
|
|
|
|
|
/// Push flattens per-camera: native-CDL cameras get split looks (fast trim),
|
|
|
|
|
/// others get a full bake.
|
|
|
|
|
public actor GradeGang {
|
|
|
|
|
private let latticeSize: Int
|
|
|
|
|
private var grades: [String: GradeStack] = [:]
|
|
|
|
|
private var drivers: [String: any CameraDriver] = [:]
|
|
|
|
|
/// Linked groups: each set shares grade edits.
|
|
|
|
|
private var groups: [Set<String>] = []
|
|
|
|
|
|
|
|
|
|
public init(latticeSize: Int = 33) {
|
|
|
|
|
self.latticeSize = latticeSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func register(slot: String, driver: any CameraDriver) {
|
|
|
|
|
drivers[slot] = driver
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func unregister(slot: String) {
|
|
|
|
|
drivers.removeValue(forKey: slot)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Link slots into one gang group (merges any groups they belong to).
|
|
|
|
|
public func link(slots: [String]) {
|
|
|
|
|
var merged = Set(slots)
|
|
|
|
|
groups.removeAll { group in
|
|
|
|
|
if group.intersection(merged).isEmpty { return false }
|
|
|
|
|
merged.formUnion(group)
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
groups.append(merged)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func unlink(slot: String) {
|
|
|
|
|
for i in groups.indices {
|
|
|
|
|
groups[i].remove(slot)
|
|
|
|
|
}
|
|
|
|
|
groups.removeAll { $0.count < 2 }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func linkedSlots(of slot: String) -> Set<String> {
|
|
|
|
|
for group in groups where group.contains(slot) {
|
|
|
|
|
return group
|
|
|
|
|
}
|
|
|
|
|
return [slot]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Set grade on a slot; propagates to its gang group.
|
|
|
|
|
public func setGrade(_ grade: GradeStack, for slot: String) {
|
|
|
|
|
for s in linkedSlots(of: slot) {
|
|
|
|
|
grades[s] = grade
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func grade(for slot: String) -> GradeStack? {
|
|
|
|
|
grades[slot]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Push slot's grade to it and all linked cameras, flattened per capabilities.
|
|
|
|
|
/// Best-effort: all cameras attempted; failures collected and thrown after.
|
|
|
|
|
public func pushGrade(from slot: String) async throws {
|
|
|
|
|
let targets = linkedSlots(of: slot)
|
|
|
|
|
guard let grade = grades[slot] else {
|
|
|
|
|
throw GangError.slotNotRegistered(slot)
|
|
|
|
|
}
|
|
|
|
|
guard targets.contains(where: { drivers[$0] != nil }) else {
|
|
|
|
|
throw GangError.slotNotRegistered(slot)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var failures: [(slot: String, error: Error)] = []
|
|
|
|
|
for target in targets.sorted() {
|
|
|
|
|
guard let driver = drivers[target] else { continue }
|
|
|
|
|
let caps = driver.capabilities
|
|
|
|
|
do {
|
2026-07-11 00:33:26 -04:00
|
|
|
if caps.lut3dSizes.isEmpty {
|
|
|
|
|
// CDL-only camera (RED over RCP2: no live LUT upload).
|
|
|
|
|
// Split CDL out; if the baked remainder is non-identity the
|
|
|
|
|
// camera cannot reproduce the grade — hard error so the
|
|
|
|
|
// operator knows monitor ≠ grade.
|
|
|
|
|
guard caps.supportsNativeCDL else {
|
|
|
|
|
throw CameraError.unsupportedLook("camera accepts neither LUTs nor CDL")
|
|
|
|
|
}
|
|
|
|
|
let look = Flattener.flatten(
|
|
|
|
|
stack: grade,
|
|
|
|
|
latticeSize: 17, // cheap lattice, only used for the identity check
|
|
|
|
|
splitLeadingCDL: true)
|
|
|
|
|
guard look.cdl != nil else {
|
|
|
|
|
throw CameraError.unsupportedLook("grade has no leading CDL; CDL-only camera cannot represent it")
|
|
|
|
|
}
|
|
|
|
|
if let lut = look.lut, !lut.isApproximatelyIdentity() {
|
|
|
|
|
throw CameraError.unsupportedLook("grade has nodes beyond CDL; CDL-only camera cannot represent them")
|
|
|
|
|
}
|
|
|
|
|
try await driver.push(look: FlattenedLook(cdl: look.cdl, lut: nil, latticeSize: 0))
|
|
|
|
|
} else {
|
|
|
|
|
// Prefer exact requested lattice; else largest supported size
|
|
|
|
|
// *below* it (downgrade only — baking a 17³-quality grade into a
|
|
|
|
|
// 99³ lattice wastes upload time and implies false precision).
|
|
|
|
|
guard let size = caps.lut3dSizes.contains(latticeSize)
|
|
|
|
|
? latticeSize
|
|
|
|
|
: caps.lut3dSizes.filter({ $0 < latticeSize }).max() else {
|
|
|
|
|
throw CameraError.unsupportedLook("no compatible LUT size <= \(latticeSize) (camera accepts \(caps.lut3dSizes))")
|
|
|
|
|
}
|
|
|
|
|
let look = Flattener.flatten(
|
|
|
|
|
stack: grade,
|
|
|
|
|
latticeSize: size,
|
|
|
|
|
splitLeadingCDL: caps.supportsNativeCDL)
|
|
|
|
|
try await driver.push(look: look)
|
2026-07-10 16:03:51 -04:00
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
failures.append((slot: target, error: error))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !failures.isEmpty {
|
|
|
|
|
throw GangError.partialFailure(failures)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|