export: ASC CDL .cdl/.ccc writers + parser, flattened .cube from grade snapshots — 6 tests
This commit is contained in:
parent
6a4a3e0976
commit
7395989c26
4 changed files with 189 additions and 6 deletions
98
Sources/ForgeExport/CDLExport.swift
Normal file
98
Sources/ForgeExport/CDLExport.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// ForgeExport
|
|
||||||
91
Tests/ForgeExportTests/ColorExportTests.swift
Normal file
91
Tests/ForgeExportTests/ColorExportTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import XCTest
|
|
||||||
|
|
||||||
final class ForgeExportPlaceholderTests: XCTestCase {
|
|
||||||
func testPlaceholder() { XCTAssertTrue(true) }
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue