camera: GradeGang — multicam link/unlink, per-capability flatten (CDL split vs full bake), lattice downgrade, best-effort push w/ partial-failure summary — 6 tests
This commit is contained in:
parent
9b89eb8964
commit
74d1287856
2 changed files with 242 additions and 0 deletions
106
Sources/ForgeCamera/GradeGang.swift
Normal file
106
Sources/ForgeCamera/GradeGang.swift
Normal file
|
|
@ -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<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 {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
Tests/ForgeCameraTests/GangTests.swift
Normal file
136
Tests/ForgeCameraTests/GangTests.swift
Normal file
|
|
@ -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<CameraState> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue