diff --git a/Sources/ForgeColor/CDL.swift b/Sources/ForgeColor/CDL.swift index a370653..cdd9be1 100644 --- a/Sources/ForgeColor/CDL.swift +++ b/Sources/ForgeColor/CDL.swift @@ -20,7 +20,7 @@ public struct CDL: Equatable, Sendable { public var isIdentity: Bool { self == .identity } /// Rec709 luma weights (ASC CDL saturation spec). - static let lumaWeights = SIMD3(0.2126, 0.7152, 0.0722) + public static let lumaWeights = SIMD3(0.2126, 0.7152, 0.0722) public func apply(_ rgb: SIMD3) -> SIMD3 { // SOP, clamped to >=0 before power for determinism (ASC pre-power clamp). diff --git a/Sources/ForgeGrade/GradeStack.swift b/Sources/ForgeGrade/GradeStack.swift new file mode 100644 index 0000000..924202c --- /dev/null +++ b/Sources/ForgeGrade/GradeStack.swift @@ -0,0 +1,96 @@ +import Foundation +import ForgeColor + +/// Named input transform (camera log -> linear Rec709 working space). +public enum InputTransform: String, Sendable, Codable, CaseIterable { + case arriLogC4AWG4 + case redLog3G10RWG + case sonySLog3SGamut3Cine + case none + + public var colorSpace: ColorSpace { + switch self { + case .arriLogC4AWG4: return .arriLogC4AWG4 + case .redLog3G10RWG: return .redLog3G10RWG + case .sonySLog3SGamut3Cine: return .sonySLog3SGamut3Cine + case .none: return .rec709 + } + } +} + +/// Named output transform (linear working space -> display). +public enum OutputTransform: String, Sendable, Codable, CaseIterable { + case rec709Display // 2.4 gamma + case none + + public func apply(_ rgb: SIMD3) -> SIMD3 { + switch self { + case .none: + return rgb + case .rec709Display: + let g: Float = 1 / 2.4 + return SIMD3( + pow(max(rgb.x, 0), g), + pow(max(rgb.y, 0), g), + pow(max(rgb.z, 0), g)) + } + } +} + +/// One node in a grade stack. +public struct GradeNode: Identifiable, Sendable { + public enum Kind: Sendable { + case inputTransform(InputTransform) + case cdl(CDL) + case saturation(Float) + case curves(CurveSet) + case lut3d(Lut3D) + case outputTransform(OutputTransform) + } + + public let id: UUID + public var kind: Kind + public var isEnabled: Bool + + public init(id: UUID = UUID(), kind: Kind, isEnabled: Bool = true) { + self.id = id + self.kind = kind + self.isEnabled = isEnabled + } + + public func apply(_ rgb: SIMD3) -> SIMD3 { + guard isEnabled else { return rgb } + switch kind { + case .inputTransform(let t): + return t.colorSpace.toLinearRec709(rgb) + case .cdl(let cdl): + return cdl.apply(rgb) + case .saturation(let sat): + let luma = (rgb * CDL.lumaWeights).sum() + return SIMD3(repeating: luma) + (rgb - SIMD3(repeating: luma)) * sat + case .curves(let set): + return set.apply(rgb) + case .lut3d(let lut): + return lut.sample(rgb) + case .outputTransform(let t): + return t.apply(rgb) + } + } +} + +/// Ordered node stack. Evaluation composes nodes top to bottom. +public struct GradeStack: Sendable { + public var nodes: [GradeNode] + + public init(nodes: [GradeNode] = []) { + self.nodes = nodes + } + + public func evaluate(_ rgb: SIMD3) -> SIMD3 { + var v = rgb + for node in nodes { + v = node.apply(v) + } + return v + } +} diff --git a/Tests/ForgeGradeTests/GradeStackTests.swift b/Tests/ForgeGradeTests/GradeStackTests.swift new file mode 100644 index 0000000..97eba40 --- /dev/null +++ b/Tests/ForgeGradeTests/GradeStackTests.swift @@ -0,0 +1,107 @@ +import XCTest +import ForgeColor +@testable import ForgeGrade + +final class GradeStackTests: XCTestCase { + + // Empty stack = identity. + func testEmptyStackIdentity() { + let stack = GradeStack(nodes: []) + let px = SIMD3(0.3, 0.5, 0.7) + XCTAssertEqual(stack.evaluate(px), px) + } + + // Single CDL node applies CDL math. + func testSingleCDLNode() { + let cdl = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1) + let stack = GradeStack(nodes: [GradeNode(kind: .cdl(cdl))]) + let out = stack.evaluate(SIMD3(0.25, 0.25, 0.25)) + XCTAssertEqual(out.x, 0.5, accuracy: 1e-6) + } + + // Ordering matters: CDL(x2) then LUT(half) != LUT(half) then CDL(x2) on clamping LUT. + func testOrderingMatters() { + let double = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1) + // LUT clamps at 1: input 0.8 doubled -> 1.6 -> LUT sample clamps to 1 -> 0.5 + let halfLut = Lut3D.build(size: 17) { $0 * 0.5 } + + let cdlFirst = GradeStack(nodes: [ + GradeNode(kind: .cdl(double)), + GradeNode(kind: .lut3d(halfLut)), + ]) + let lutFirst = GradeStack(nodes: [ + GradeNode(kind: .lut3d(halfLut)), + GradeNode(kind: .cdl(double)), + ]) + let px = SIMD3(0.8, 0.8, 0.8) + let a = cdlFirst.evaluate(px) // 0.8*2=1.6 clamp-> lut(1.0)=0.5 + let b = lutFirst.evaluate(px) // lut(0.8)=0.4 -> *2 = 0.8 + XCTAssertEqual(a.x, 0.5, accuracy: 1e-4) + XCTAssertEqual(b.x, 0.8, accuracy: 1e-4) + } + + // Bypassed node skipped. + func testBypassSkipsNode() { + let double = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1) + var node = GradeNode(kind: .cdl(double)) + node.isEnabled = false + let stack = GradeStack(nodes: [node]) + let px = SIMD3(0.25, 0.25, 0.25) + XCTAssertEqual(stack.evaluate(px), px) + } + + // Saturation node. + func testSaturationNode() { + let stack = GradeStack(nodes: [GradeNode(kind: .saturation(0))]) + let out = stack.evaluate(SIMD3(1, 0, 0)) + XCTAssertEqual(out.x, 0.2126, accuracy: 1e-5) + XCTAssertEqual(out.y, 0.2126, accuracy: 1e-5) + } + + // Curves node. + func testCurvesNode() { + var set = CurveSet.identity + set.master = Curve(points: [.init(x: 0, y: 0), .init(x: 1, y: 0.5)]) + let stack = GradeStack(nodes: [GradeNode(kind: .curves(set))]) + let out = stack.evaluate(SIMD3(1, 1, 1)) + XCTAssertEqual(out.x, 0.5, accuracy: 1e-5) + } + + // Input transform node decodes camera log to linear Rec709. + func testInputTransformNode() { + let cs = ColorSpace.arriLogC4AWG4 + let encodedGray = SIMD3(repeating: cs.transfer.encode(0.18)) + let stack = GradeStack(nodes: [GradeNode(kind: .inputTransform(.arriLogC4AWG4))]) + let out = stack.evaluate(encodedGray) + XCTAssertEqual(out.x, 0.18, accuracy: 0.002) + } + + // Output transform node: linear -> Rec709 display (2.4 gamma). + func testOutputTransformNode() { + let stack = GradeStack(nodes: [GradeNode(kind: .outputTransform(.rec709Display))]) + let out = stack.evaluate(SIMD3(0.18, 0.18, 0.18)) + XCTAssertEqual(out.x, pow(0.18, 1 / 2.4), accuracy: 1e-4) + } + + // Full stack on known vector: input LogC4 -> CDL gain -> output 2.4 gamma. + func testFullStackKnownVector() { + let cs = ColorSpace.arriLogC4AWG4 + let gain = CDL(slope: SIMD3(2, 2, 2), offset: .zero, power: .one, saturation: 1) + let stack = GradeStack(nodes: [ + GradeNode(kind: .inputTransform(.arriLogC4AWG4)), + GradeNode(kind: .cdl(gain)), + GradeNode(kind: .outputTransform(.rec709Display)), + ]) + let encodedGray = SIMD3(repeating: cs.transfer.encode(0.18)) + let out = stack.evaluate(encodedGray) + let expected = pow(0.36, 1 / 2.4) // 0.18 * 2 -> display gamma + XCTAssertEqual(out.x, Float(expected), accuracy: 0.003) + } + + // Node IDs unique + stable. + func testNodeIDsUnique() { + let a = GradeNode(kind: .saturation(1)) + let b = GradeNode(kind: .saturation(1)) + XCTAssertNotEqual(a.id, b.id) + } +} diff --git a/Tests/ForgeGradeTests/PlaceholderTests.swift b/Tests/ForgeGradeTests/PlaceholderTests.swift deleted file mode 100644 index 2baca9e..0000000 --- a/Tests/ForgeGradeTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeGradePlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -}