diff --git a/Sources/ForgeExport/CDLExport.swift b/Sources/ForgeExport/CDLExport.swift new file mode 100644 index 0000000..b833e5e --- /dev/null +++ b/Sources/ForgeExport/CDLExport.swift @@ -0,0 +1,98 @@ +import Foundation +import ForgeColor +import ForgeGrade + +public enum ExportError: Error, Equatable { + case malformed(String) +} + +/// ASC CDL XML writers/parser (.cdl single, .ccc collection). urn:ASC:CDL:v1.01. +public enum CDLExport { + + private static func f(_ v: Float) -> String { + String(format: "%.6f", v) + } + + private static func triple(_ v: SIMD3) -> String { + "\(f(v.x)) \(f(v.y)) \(f(v.z))" + } + + static func correctionBlock(_ cdl: CDL, id: String, indent: String) -> String { + """ + \(indent) + \(indent) + \(indent) \(triple(cdl.slope)) + \(indent) \(triple(cdl.offset)) + \(indent) \(triple(cdl.power)) + \(indent) + \(indent) + \(indent) \(f(cdl.saturation)) + \(indent) + \(indent) + """ + } + + /// Single-correction .cdl file. + public static func writeCDL(_ cdl: CDL, id: String) -> String { + """ + + + + \(correctionBlock(cdl, id: id, indent: " ")) + + + + """ + } + + /// Multi-clip .ccc collection. + public static func writeCCC(_ entries: [(id: String, cdl: CDL)]) -> String { + var out = """ + + + + """ + for e in entries { + out += correctionBlock(e.cdl, id: e.id, indent: " ") + "\n" + } + out += "\n" + return out + } + + /// Parse first ColorCorrection from .cdl/.ccc text. Regex-based scanner — + /// tolerant of wrappers, strict on numeric content. + public static func parseCDL(_ xml: String) throws -> CDL { + func extract(_ tag: String) throws -> String { + guard let open = xml.range(of: "<\(tag)>"), + let close = xml.range(of: ""), + open.upperBound <= close.lowerBound else { + throw ExportError.malformed("missing <\(tag)>") + } + return String(xml[open.upperBound.. SIMD3 { + let parts = text.split(separator: " ").compactMap { Float($0) } + guard parts.count == 3 else { throw ExportError.malformed("bad triple in <\(tag)>") } + return SIMD3(parts[0], parts[1], parts[2]) + } + + let slope = try parseTriple(try extract("Slope"), tag: "Slope") + let offset = try parseTriple(try extract("Offset"), tag: "Offset") + let power = try parseTriple(try extract("Power"), tag: "Power") + let satText = (try? extract("Saturation")) ?? "1.0" + guard let sat = Float(satText) else { throw ExportError.malformed("bad Saturation") } + return CDL(slope: slope, offset: offset, power: power, saturation: sat) + } +} + +/// Flattened .cube export from stored grade snapshots. +public enum CubeExport { + public static func fromSnapshot(_ snapshot: Data, latticeSize: Int, title: String) throws -> String { + let stack = try GradeSnapshot.decode(snapshot) + let look = Flattener.flatten(stack: stack, latticeSize: latticeSize, splitLeadingCDL: false) + guard let lut = look.lut else { + throw ExportError.malformed("flatten produced no LUT") + } + return CubeFile.write(lut, title: title) + } +} diff --git a/Sources/ForgeExport/ForgeExport.swift b/Sources/ForgeExport/ForgeExport.swift deleted file mode 100644 index d9de51d..0000000 --- a/Sources/ForgeExport/ForgeExport.swift +++ /dev/null @@ -1 +0,0 @@ -// ForgeExport diff --git a/Tests/ForgeExportTests/ColorExportTests.swift b/Tests/ForgeExportTests/ColorExportTests.swift new file mode 100644 index 0000000..3372aca --- /dev/null +++ b/Tests/ForgeExportTests/ColorExportTests.swift @@ -0,0 +1,91 @@ +import XCTest +import ForgeColor +import ForgeGrade +@testable import ForgeExport + +final class ColorExportTests: XCTestCase { + + let cdl = CDL( + slope: SIMD3(1.2, 1.0, 0.85), + offset: SIMD3(0.01, -0.02, 0.0), + power: SIMD3(0.95, 1.0, 1.1), + saturation: 1.25) + + // .cdl XML golden: ColorDecisionList > ColorDecision > ColorCorrection > SOPNode/SatNode. + func testCDLFileGolden() { + let xml = CDLExport.writeCDL(cdl, id: "A001C001") + let expected = """ + + + + + + 1.200000 1.000000 0.850000 + 0.010000 -0.020000 0.000000 + 0.950000 1.000000 1.100000 + + + 1.250000 + + + + + + """ + XCTAssertEqual(xml, expected) + } + + // .ccc multi-clip golden. + func testCCCFileGolden() { + let second = CDL(slope: .one, offset: .zero, power: .one, saturation: 1) + let xml = CDLExport.writeCCC([("A001C001", cdl), ("A001C002", second)]) + XCTAssertTrue(xml.hasPrefix("\n")) + XCTAssertTrue(xml.contains("")) + XCTAssertTrue(xml.contains("")) + XCTAssertTrue(xml.contains("1.000000 1.000000 1.000000")) + XCTAssertTrue(xml.hasSuffix("\n")) + } + + // Round trip: written .cdl parses back to same values. + func testCDLParseRoundTrip() throws { + let xml = CDLExport.writeCDL(cdl, id: "X") + let parsed = try CDLExport.parseCDL(xml) + XCTAssertEqual(parsed.slope.x, cdl.slope.x, accuracy: 1e-6) + XCTAssertEqual(parsed.offset.y, cdl.offset.y, accuracy: 1e-6) + XCTAssertEqual(parsed.power.z, cdl.power.z, accuracy: 1e-6) + XCTAssertEqual(parsed.saturation, cdl.saturation, accuracy: 1e-6) + } + + // Malformed CDL XML rejected. + func testMalformedCDLRejected() { + XCTAssertThrowsError(try CDLExport.parseCDL("")) + XCTAssertThrowsError(try CDLExport.parseCDL("a b c")) + } + + // Shot grade snapshot -> flattened .cube export. + func testCubeFromSnapshot() throws { + let stack = GradeStack(nodes: [ + GradeNode(kind: .cdl(cdl)), + GradeNode(kind: .saturation(0.9)), + ]) + let snapshot = try GradeSnapshot.encode(stack) + let cube = try CubeExport.fromSnapshot(snapshot, latticeSize: 17, title: "A001C001") + XCTAssertTrue(cube.contains("TITLE \"A001C001\"")) + XCTAssertTrue(cube.contains("LUT_3D_SIZE 17")) + // Parses back as valid LUT and matches direct eval at a probe point. + let lut = try CubeFile.parse(cube) + let probe = SIMD3(0.5, 0.5, 0.5) + let direct = stack.evaluate(probe) + let viaLut = lut.sample(probe) + XCTAssertEqual(viaLut.x, direct.x, accuracy: 0.01) + } + + // Snapshot with no nodes -> identity cube. + func testCubeFromEmptySnapshot() throws { + let snapshot = try GradeSnapshot.encode(GradeStack(nodes: [])) + let cube = try CubeExport.fromSnapshot(snapshot, latticeSize: 9, title: "empty") + let lut = try CubeFile.parse(cube) + let out = lut.sample(SIMD3(0.3, 0.6, 0.9)) + XCTAssertEqual(out.x, 0.3, accuracy: 1e-4) + } +} diff --git a/Tests/ForgeExportTests/PlaceholderTests.swift b/Tests/ForgeExportTests/PlaceholderTests.swift deleted file mode 100644 index 42fceb0..0000000 --- a/Tests/ForgeExportTests/PlaceholderTests.swift +++ /dev/null @@ -1,5 +0,0 @@ -import XCTest - -final class ForgeExportPlaceholderTests: XCTestCase { - func testPlaceholder() { XCTAssertTrue(true) } -}