rainbow-dragon/Sources/ForgeCamera/GradeGang.swift

127 lines
5 KiB
Swift

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 {
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)
}
}
}