Skip to main content

oxihuman_export/
cdl_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! ASC CDL (Color Decision List) export.
6
7/// A single CDL correction node (SOP + SAT).
8#[derive(Debug, Clone)]
9pub struct CdlNode {
10    pub id: String,
11    pub slope: [f32; 3],
12    pub offset: [f32; 3],
13    pub power: [f32; 3],
14    pub saturation: f32,
15}
16
17impl Default for CdlNode {
18    fn default() -> Self {
19        Self {
20            id: "default".to_string(),
21            slope: [1.0, 1.0, 1.0],
22            offset: [0.0, 0.0, 0.0],
23            power: [1.0, 1.0, 1.0],
24            saturation: 1.0,
25        }
26    }
27}
28
29/// A CDL export containing one or more nodes.
30#[derive(Debug, Clone)]
31pub struct CdlExport {
32    pub version: String,
33    pub nodes: Vec<CdlNode>,
34}
35
36/// Create a new CDL export.
37pub fn new_cdl_export() -> CdlExport {
38    CdlExport {
39        version: "1.01".to_string(),
40        nodes: Vec::new(),
41    }
42}
43
44/// Add a CDL node.
45pub fn cdl_add_node(export: &mut CdlExport, node: CdlNode) {
46    export.nodes.push(node);
47}
48
49/// Add a default identity CDL node.
50pub fn cdl_add_identity(export: &mut CdlExport, id: &str) {
51    let node = CdlNode {
52        id: id.to_string(),
53        ..CdlNode::default()
54    };
55    export.nodes.push(node);
56}
57
58/// Return the node count.
59pub fn cdl_node_count(export: &CdlExport) -> usize {
60    export.nodes.len()
61}
62
63/// Find a node by ID.
64pub fn cdl_find_node<'a>(export: &'a CdlExport, id: &str) -> Option<&'a CdlNode> {
65    export.nodes.iter().find(|n| n.id == id)
66}
67
68/// Validate: saturation >= 0, slope components > 0.
69pub fn validate_cdl(export: &CdlExport) -> bool {
70    export
71        .nodes
72        .iter()
73        .all(|n| n.saturation >= 0.0 && n.slope[0] >= 0.0 && n.slope[1] >= 0.0 && n.slope[2] >= 0.0)
74}
75
76/// Serialize to CDL XML.
77pub fn cdl_to_xml(export: &CdlExport) -> String {
78    let mut out = format!(
79        "<?xml version=\"1.0\"?>\n<ColorDecisionList xmlns=\"urn:ASC:CDL:v{}\">\n",
80        export.version
81    );
82    for node in &export.nodes {
83        out.push_str(&format!(
84            "  <ColorDecision>\n    <ColorCorrection id=\"{}\">\n      <SOPNode>\n        <Slope>{:.6} {:.6} {:.6}</Slope>\n        <Offset>{:.6} {:.6} {:.6}</Offset>\n        <Power>{:.6} {:.6} {:.6}</Power>\n      </SOPNode>\n      <SatNode><Saturation>{:.6}</Saturation></SatNode>\n    </ColorCorrection>\n  </ColorDecision>\n",
85            node.id,
86            node.slope[0], node.slope[1], node.slope[2],
87            node.offset[0], node.offset[1], node.offset[2],
88            node.power[0], node.power[1], node.power[2],
89            node.saturation
90        ));
91    }
92    out.push_str("</ColorDecisionList>\n");
93    out
94}
95
96/// Estimate the CDL file size.
97pub fn cdl_size_bytes(export: &CdlExport) -> usize {
98    cdl_to_xml(export).len()
99}
100
101/// Apply CDL to a single RGB value (float, linear).
102pub fn apply_cdl(node: &CdlNode, rgb: [f32; 3]) -> [f32; 3] {
103    let sop: [f32; 3] = [
104        (rgb[0] * node.slope[0] + node.offset[0])
105            .max(0.0)
106            .powf(node.power[0]),
107        (rgb[1] * node.slope[1] + node.offset[1])
108            .max(0.0)
109            .powf(node.power[1]),
110        (rgb[2] * node.slope[2] + node.offset[2])
111            .max(0.0)
112            .powf(node.power[2]),
113    ];
114    let lum = 0.2126 * sop[0] + 0.7152 * sop[1] + 0.0722 * sop[2];
115    [
116        lum + node.saturation * (sop[0] - lum),
117        lum + node.saturation * (sop[1] - lum),
118        lum + node.saturation * (sop[2] - lum),
119    ]
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn sample() -> CdlExport {
127        let mut exp = new_cdl_export();
128        cdl_add_identity(&mut exp, "shot_010");
129        let node = CdlNode {
130            id: "shot_020".to_string(),
131            slope: [1.1, 1.0, 0.9],
132            saturation: 1.2,
133            ..CdlNode::default()
134        };
135        cdl_add_node(&mut exp, node);
136        exp
137    }
138
139    #[test]
140    fn test_node_count() {
141        assert_eq!(cdl_node_count(&sample()), 2);
142    }
143
144    #[test]
145    fn test_find_node() {
146        let exp = sample();
147        assert!(cdl_find_node(&exp, "shot_010").is_some());
148        assert!(cdl_find_node(&exp, "none").is_none());
149    }
150
151    #[test]
152    fn test_validate_valid() {
153        assert!(validate_cdl(&sample()));
154    }
155
156    #[test]
157    fn test_validate_bad_slope() {
158        let mut exp = new_cdl_export();
159        let mut node = CdlNode::default();
160        node.slope[0] = -1.0;
161        cdl_add_node(&mut exp, node);
162        assert!(!validate_cdl(&exp));
163    }
164
165    #[test]
166    fn test_to_xml() {
167        let s = cdl_to_xml(&sample());
168        assert!(s.contains("shot_010"));
169    }
170
171    #[test]
172    fn test_size_positive() {
173        assert!(cdl_size_bytes(&sample()) > 0);
174    }
175
176    #[test]
177    fn test_apply_cdl_identity() {
178        let node = CdlNode::default();
179        let rgb = [0.5f32, 0.5, 0.5];
180        let out = apply_cdl(&node, rgb);
181        assert!((out[0] - 0.5).abs() < 1e-4);
182    }
183
184    #[test]
185    fn test_apply_cdl_slope() {
186        let node = CdlNode {
187            slope: [2.0, 2.0, 2.0],
188            ..CdlNode::default()
189        };
190        let out = apply_cdl(&node, [0.5, 0.5, 0.5]);
191        assert!(out[0] > 0.5);
192    }
193
194    #[test]
195    fn test_default_node_identity() {
196        let node = CdlNode::default();
197        assert!((node.saturation - 1.0).abs() < 1e-6);
198    }
199}