export: ASC CDL .cdl/.ccc writers + parser, flattened .cube from grade snapshots — 6 tests

This commit is contained in:
Forge Dev 2026-07-10 19:43:03 +00:00
parent 6a4a3e0976
commit 7395989c26
4 changed files with 189 additions and 6 deletions

View file

@ -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<Float>) -> String {
"\(f(v.x)) \(f(v.y)) \(f(v.z))"
}
static func correctionBlock(_ cdl: CDL, id: String, indent: String) -> String {
"""
\(indent)<ColorCorrection id="\(id)">
\(indent) <SOPNode>
\(indent) <Slope>\(triple(cdl.slope))</Slope>
\(indent) <Offset>\(triple(cdl.offset))</Offset>
\(indent) <Power>\(triple(cdl.power))</Power>
\(indent) </SOPNode>
\(indent) <SatNode>
\(indent) <Saturation>\(f(cdl.saturation))</Saturation>
\(indent) </SatNode>
\(indent)</ColorCorrection>
"""
}
/// Single-correction .cdl file.
public static func writeCDL(_ cdl: CDL, id: String) -> String {
"""
<?xml version="1.0" encoding="UTF-8"?>
<ColorDecisionList xmlns="urn:ASC:CDL:v1.01">
<ColorDecision>
\(correctionBlock(cdl, id: id, indent: " "))
</ColorDecision>
</ColorDecisionList>
"""
}
/// Multi-clip .ccc collection.
public static func writeCCC(_ entries: [(id: String, cdl: CDL)]) -> String {
var out = """
<?xml version="1.0" encoding="UTF-8"?>
<ColorCorrectionCollection xmlns="urn:ASC:CDL:v1.01">
"""
for e in entries {
out += correctionBlock(e.cdl, id: e.id, indent: " ") + "\n"
}
out += "</ColorCorrectionCollection>\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: "</\(tag)>"),
open.upperBound <= close.lowerBound else {
throw ExportError.malformed("missing <\(tag)>")
}
return String(xml[open.upperBound..<close.lowerBound]).trimmingCharacters(in: .whitespacesAndNewlines)
}
func parseTriple(_ text: String, tag: String) throws -> SIMD3<Float> {
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)
}
}

View file

@ -1 +0,0 @@
// ForgeExport

View file

@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
<ColorDecisionList xmlns="urn:ASC:CDL:v1.01">
<ColorDecision>
<ColorCorrection id="A001C001">
<SOPNode>
<Slope>1.200000 1.000000 0.850000</Slope>
<Offset>0.010000 -0.020000 0.000000</Offset>
<Power>0.950000 1.000000 1.100000</Power>
</SOPNode>
<SatNode>
<Saturation>1.250000</Saturation>
</SatNode>
</ColorCorrection>
</ColorDecision>
</ColorDecisionList>
"""
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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<ColorCorrectionCollection xmlns=\"urn:ASC:CDL:v1.01\">"))
XCTAssertTrue(xml.contains("<ColorCorrection id=\"A001C001\">"))
XCTAssertTrue(xml.contains("<ColorCorrection id=\"A001C002\">"))
XCTAssertTrue(xml.contains("<Slope>1.000000 1.000000 1.000000</Slope>"))
XCTAssertTrue(xml.hasSuffix("</ColorCorrectionCollection>\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("<not-cdl/>"))
XCTAssertThrowsError(try CDLExport.parseCDL("<SOPNode><Slope>a b c</Slope></SOPNode>"))
}
// 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<Float>(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)
}
}

View file

@ -1,5 +0,0 @@
import XCTest
final class ForgeExportPlaceholderTests: XCTestCase {
func testPlaceholder() { XCTAssertTrue(true) }
}