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] = [] 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 { 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 { 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) } } catch { failures.append((slot: target, error: error)) } } if !failures.isEmpty { throw GangError.partialFailure(failures) } } }