From 74d12878566c391d540cccfb335a90360ba948ba Mon Sep 17 00:00:00 2001 From: Forge Dev Date: Fri, 10 Jul 2026 20:03:51 +0000 Subject: [PATCH] =?UTF-8?q?camera:=20GradeGang=20=E2=80=94=20multicam=20li?= =?UTF-8?q?nk/unlink,=20per-capability=20flatten=20(CDL=20split=20vs=20ful?= =?UTF-8?q?l=20bake),=20lattice=20downgrade,=20best-effort=20push=20w/=20p?= =?UTF-8?q?artial-failure=20summary=20=E2=80=94=206=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/ForgeCamera/GradeGang.swift | 106 +++++++++++++++++++ Tests/ForgeCameraTests/GangTests.swift | 136 +++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 Sources/ForgeCamera/GradeGang.swift create mode 100644 Tests/ForgeCameraTests/GangTests.swift diff --git a/Sources/ForgeCamera/GradeGang.swift b/Sources/ForgeCamera/GradeGang.swift new file mode 100644 index 0000000..25323ff --- /dev/null +++ b/Sources/ForgeCamera/GradeGang.swift @@ -0,0 +1,106 @@ +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 { + // 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) + } + } +} diff --git a/Tests/ForgeCameraTests/GangTests.swift b/Tests/ForgeCameraTests/GangTests.swift new file mode 100644 index 0000000..964507c --- /dev/null +++ b/Tests/ForgeCameraTests/GangTests.swift @@ -0,0 +1,136 @@ +import XCTest +import Foundation +import ForgeGrade +import ForgeColor +@testable import ForgeCamera + +/// Recording driver: captures pushes, declares capabilities. +actor RecordingDriver: CameraDriver { + nonisolated let capabilities: CameraCapabilities + private(set) var pushedLooks = [FlattenedLook]() + + init(nativeCDL: Bool, lutSizes: [Int] = [17, 33]) { + capabilities = CameraCapabilities( + vendor: .simulated, supportsNativeCDL: nativeCDL, + lut3dSizes: lutSizes, metadataFields: []) + } + + nonisolated var state: AsyncStream { + AsyncStream { _ in } + } + + func connect() async throws {} + func disconnect() async {} + func push(look: FlattenedLook) async throws { + pushedLooks.append(look) + } +} + +final class GangTests: XCTestCase { + + func makeGrade() -> GradeStack { + GradeStack(nodes: [ + GradeNode(kind: .cdl(CDL(slope: SIMD3(1.2, 1, 0.9), offset: .zero, power: .one, saturation: 1.1))), + GradeNode(kind: .saturation(0.95)), + ]) + } + + // Push to ganged slots: each camera gets a look flattened per its own capabilities. + func testGangPushRespectsCapabilities() async throws { + let arriLike = RecordingDriver(nativeCDL: true) + let sonyLike = RecordingDriver(nativeCDL: false) + + let gang = GradeGang(latticeSize: 17) + await gang.setGrade(makeGrade(), for: "A") + await gang.link(slots: ["A", "B"]) + await gang.register(slot: "A", driver: arriLike) + await gang.register(slot: "B", driver: sonyLike) + + try await gang.pushGrade(from: "A") + + let arriPushes = await arriLike.pushedLooks + let sonyPushes = await sonyLike.pushedLooks + XCTAssertEqual(arriPushes.count, 1) + XCTAssertEqual(sonyPushes.count, 1) + // ARRI-like gets split CDL; Sony-like full bake, no CDL. + XCTAssertNotNil(arriPushes[0].cdl) + XCTAssertNil(sonyPushes[0].cdl) + XCTAssertNotNil(sonyPushes[0].lut) + } + + // Ganged edit propagates grade to linked slots. + func testGangPropagatesGrade() async throws { + let gang = GradeGang(latticeSize: 17) + await gang.link(slots: ["A", "B", "C"]) + await gang.setGrade(makeGrade(), for: "A") + + let gradeB = await gang.grade(for: "B") + let gradeC = await gang.grade(for: "C") + XCTAssertEqual(gradeB?.nodes.count, 2) + XCTAssertEqual(gradeC?.nodes.count, 2) + } + + // Unlink stops propagation. + func testUnlinkStopsPropagation() async throws { + let gang = GradeGang(latticeSize: 17) + await gang.link(slots: ["A", "B"]) + await gang.setGrade(makeGrade(), for: "A") + await gang.unlink(slot: "B") + + // New grade on A only. + let solo = GradeStack(nodes: [GradeNode(kind: .saturation(0.5))]) + await gang.setGrade(solo, for: "A") + + let gradeA = await gang.grade(for: "A") + let gradeB = await gang.grade(for: "B") + XCTAssertEqual(gradeA?.nodes.count, 1) + XCTAssertEqual(gradeB?.nodes.count, 2) // stays on pre-unlink grade + } + + // Per-slot override: editing an unlinked slot never touches others. + func testSoloEditIsolated() async throws { + let gang = GradeGang(latticeSize: 17) + await gang.setGrade(makeGrade(), for: "A") + await gang.setGrade(GradeStack(nodes: []), for: "B") + + let gradeA = await gang.grade(for: "A") + let gradeB = await gang.grade(for: "B") + XCTAssertEqual(gradeA?.nodes.count, 2) + XCTAssertEqual(gradeB?.nodes.count, 0) + } + + // Push from an unregistered slot throws. + func testPushUnregisteredThrows() async { + let gang = GradeGang(latticeSize: 17) + await gang.setGrade(makeGrade(), for: "X") + do { + try await gang.pushGrade(from: "X") + XCTFail("expected throw") + } catch {} + } + + // Push failure on one camera doesn't block others (best-effort multi-push). + func testPushPartialFailure() async throws { + let good = RecordingDriver(nativeCDL: true) + let bad = RecordingDriver(nativeCDL: false, lutSizes: [99]) // rejects 17 + + let gang = GradeGang(latticeSize: 17) + await gang.link(slots: ["A", "B"]) + await gang.setGrade(makeGrade(), for: "A") + await gang.register(slot: "A", driver: good) + await gang.register(slot: "B", driver: bad) + + // bad driver: flatten will target unsupported size -> per-slot error collected. + do { + try await gang.pushGrade(from: "A") + XCTFail("expected partial failure to throw summary") + } catch let e as GangError { + guard case .partialFailure(let failures) = e else { return XCTFail("wrong error") } + XCTAssertEqual(failures.count, 1) + XCTAssertEqual(failures[0].slot, "B") + } + // Good camera still got its push. + let pushes = await good.pushedLooks + XCTAssertEqual(pushes.count, 1) + } +}