From 93ba0d34cbbb8a1c74dfb36b12e4a2c43169dc99 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:17:52 +0000 Subject: [PATCH] refactor(effects): move src/ocio_config.rs tests to src/ocio_config/tests.rs --- crates/effects/src/ocio_config.rs | 399 +----------------------- crates/effects/src/ocio_config/tests.rs | 396 +++++++++++++++++++++++ 2 files changed, 397 insertions(+), 398 deletions(-) create mode 100644 crates/effects/src/ocio_config/tests.rs diff --git a/crates/effects/src/ocio_config.rs b/crates/effects/src/ocio_config.rs index 41c74b4e4..af860c2fc 100644 --- a/crates/effects/src/ocio_config.rs +++ b/crates/effects/src/ocio_config.rs @@ -1331,401 +1331,4 @@ pub fn load_config(src: &str) -> Result, String> { } #[cfg(test)] -mod tests { - use super::*; - - const CONFIG: &str = r#" -ocio_profile_version: 2 - -name: Test Config # a comment -search_path: "luts:other" -roles: - scene_linear: lin - color_timing: "log2" - -displays: - sRGB: - - ! {name: Standard, colorspace: gamma22} - - ! {name: Raw, colorspace: raw} - -colorspaces: - - ! - name: lin - family: "" - description: | - The reference. - Linear. - isdata: false - - - ! - name: gamma22 - aliases: [g22, "gamma 2.2"] - to_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} - - - ! - name: half - from_reference: ! {matrix: [0.5, 0, 0, 0, - 0, 0.5, 0, 0, - 0, 0, 0.5, 0, - 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]} - - - ! - name: log2 - from_reference: ! - children: - - ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} - - ! {base: 2} - - - ! - name: affine - to_reference: ! {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse} - - - ! - name: lut - to_reference: ! {src: double.cube, interpolation: linear} - - - ! - name: raw - isdata: true - - - ! - name: weird - to_reference: ! {exposure: 1} -"#; - - fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { - (0..3).all(|i| (a[i] - b[i]).abs() < tol) - } - - fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("ec-ocio-{name}-{}", std::process::id())); - std::fs::create_dir_all(dir.join("luts")).unwrap(); - dir - } - - #[test] - fn yaml_subset() { - let y = parse_yaml("a: 1\nb:\n - x\n - {k: [1, 2], s: 'q: r'}\nc: ! {v: 3}\nd:\n- 4\n- 5\n"); - assert_eq!(y.get("a").and_then(Yaml::str), Some("1")); - assert_eq!(y.get("b").unwrap().seq().len(), 2); - assert_eq!(y.get("b").unwrap().seq()[1].get("k").unwrap().nums(), vec![1.0, 2.0]); - assert_eq!(y.get("b").unwrap().seq()[1].get("s").and_then(Yaml::str), Some("q: r")); - assert_eq!(y.get("c").unwrap().tag(), Some("T")); - assert_eq!(y.get("c").unwrap().get("v").and_then(Yaml::str), Some("3")); - assert_eq!(y.get("d").unwrap().seq().len(), 2, "a sequence at the key's indent"); - } - - /// Hostile nesting (flow brackets and block indentation) is cut off instead of exhausting - /// the stack. - #[test] - fn yaml_nesting_is_bounded() { - let flow = format!("a: {}\nb: 1\n", "[".repeat(200_000)); - assert!(matches!(parse_yaml(&flow), Yaml::Map(_))); - let block: String = (0..2_000).map(|i| format!("{}k{i}:\n", " ".repeat(i))).collect(); - assert!(matches!(parse_yaml(&block), Yaml::Map(_))); - let dashes: String = (0..2_000).map(|i| format!("{}- x{i}:\n", " ".repeat(2 * i))).collect(); - let _ = parse_yaml(&dashes); - assert!(Config::parse(&flow, Path::new(".")).is_err()); - } - - #[test] - fn parses_spaces_roles_displays_and_converts() { - let dir = temp_dir("basic"); - // A 1D LUT that doubles (0..1 → 0..2). - std::fs::write(dir.join("luts/double.cube"), "LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n0 0 0\n2 2 2\n").unwrap(); - let c = Config::parse(CONFIG, &dir).unwrap(); - assert_eq!(c.name, "Test Config"); - assert_eq!(c.search_path, vec!["luts", "other"]); - assert_eq!(c.space_names(), vec!["lin", "gamma22", "half", "log2", "affine", "lut", "raw", "weird"]); - // Roles and aliases resolve. - assert_eq!(c.space("scene_linear").unwrap().name, "lin"); - assert_eq!(c.space("G22").unwrap().name, "gamma22"); - assert_eq!(c.space("gamma 2.2").unwrap().name, "gamma22"); - assert_eq!(c.view("sRGB", "Standard").unwrap().1.colorspace, "gamma22"); - let sp = |n: &str| c.space(n).unwrap(); - // gamma22 → lin: decode with the exponent. - let g = c.convert(sp("gamma22"), sp("lin"), [0.5, 0.25, 1.0]); - assert!(close(g, [0.5f64.powf(2.2), 0.25f64.powf(2.2), 1.0], 1e-9), "{g:?}"); - // lin → gamma22: the inverse (only to_reference given). - let back = c.convert(sp("lin"), sp("gamma22"), g); - assert!(close(back, [0.5, 0.25, 1.0], 1e-9), "{back:?}"); - // The display's view is that conversion. - assert!(close(c.display_path(sp("lin"), "sRGB", "Standard").apply(g, false), [0.5, 0.25, 1.0], 1e-9)); - // lin → half: matrix and offset; and back through the inverse matrix. - let h = c.convert(sp("lin"), sp("half"), [0.4, 0.6, 1.0]); - assert!(close(h, [0.3, 0.4, 0.6], 1e-9), "{h:?}"); - assert!(close(c.convert(sp("half"), sp("lin"), h), [0.4, 0.6, 1.0], 1e-9)); - // lin → log2: a group (×2 then log2): 0.5 → 0; 2 → 2. - let l = c.convert(sp("lin"), sp("log2"), [0.5, 2.0, 4.0]); - assert!(close(l, [0.0, 2.0, 3.0], 1e-9), "{l:?}"); - assert!(close(c.convert(sp("log2"), sp("lin"), l), [0.5, 2.0, 4.0], 1e-9)); - // LogAffine (inverse direction as to_reference) round-trips. - let a = c.convert(sp("lin"), sp("affine"), [0.18, 0.5, 1.0]); - let expect = [0.18f64, 0.5, 1.0].map(|v| 0.5 * (2.0 * v + 0.01).log10() + 0.2); - assert!(close(a, expect, 1e-9), "{a:?} vs {expect:?}"); - assert!(close(c.convert(sp("affine"), sp("lin"), a), [0.18, 0.5, 1.0], 1e-9)); - // FileTransform found on the search path. - let f = c.convert(sp("lut"), sp("lin"), [0.25, 0.5, 0.0]); - assert!(close(f, [0.5, 1.0, 0.0], 1e-6), "{f:?}"); - // Data spaces pass through; unsupported transforms are reported. - assert_eq!(c.convert(sp("raw"), sp("gamma22"), [0.3, 0.3, 0.3]), [0.3, 0.3, 0.3]); - assert_eq!(c.unsupported(), vec!["ExposureContrastTransform".to_string()]); - assert_eq!(c.path(sp("lin"), sp("weird")).unsupported_list(), vec!["ExposureContrastTransform".to_string()]); - assert!(c.path(sp("lin"), sp("half")).unsupported_list().is_empty()); - // Loading from a file path (cached), and errors. - std::fs::write(dir.join("config.ocio"), CONFIG).unwrap(); - let loaded = load_config(&dir.join("config.ocio").to_string_lossy()).unwrap(); - assert_eq!(loaded.spaces.len(), 8); - assert!(load_config("/no/such/config.ocio").is_err()); - assert!(Config::parse("just: text\n", &dir).is_err()); - } - - /// The OCIO Color Space and Display Transform effects with Configuration ▸ Custom. - #[test] - fn effects_use_a_custom_config() { - use effectcraft_keyframe::Value; - let cfg = "ocio_profile_version: 1\nroles:\n default: lin\ndisplays:\n Monitor:\n - ! {name: Video, colorspace: g2}\ncolorspaces:\n - !\n name: lin\n - !\n name: g2\n to_reference: ! {value: [2, 2, 2, 1]}\n"; - let img = crate::Image::filled(2, 2, [0.5, 0.25, 1.0, 1.0]); - let fx = |id: &str, extra: &[(&str, Value)]| { - let mut v = vec![("config", Value::Enum(1)), ("configFile", Value::Str(cfg.into()))]; - v.extend(extra.iter().cloned()); - crate::run_fx(id, &v, img.clone(), 0.0, crate::EffectEnv::default()).img.data[0] - }; - let o = fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("default".into()))]); - assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.0625).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); - // Inverse direction. - let o = fx( - "ec.color.ociocolorspace", - &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("lin".into())), ("direction", Value::Enum(1))], - ); - assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); - // Display ▸ view → its colour space. - let o = fx( - "ec.color.ociodisplay", - &[("sourceName", Value::Str("lin".into())), ("displayName", Value::Str("Monitor".into())), ("viewName", Value::Str("Video".into()))], - ); - assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); - // An unreadable config leaves pixels alone. - let o = crate::run_fx( - "ec.color.ociocolorspace", - &[("config", Value::Enum(1)), ("configFile", Value::Str("/missing.ocio".into()))], - img.clone(), - 0.0, - crate::EffectEnv::default(), - ); - assert_eq!(o.img.data, img.data); - } - - /// A config laid out like Blender 4.x / 5.x's (written for this test): the reference space is - /// CIE XYZ with the E white, the D65 spaces adapt through a `.spimtx` matrix, sRGB is a - /// ColorSpaceTransform plus an ExponentWithLinearTransform, the display-referred spaces meet - /// the scene through the default view transform, and a log space uses an - /// AllocationTransform. - fn blender_like(dir: &Path) -> Config { - use effectcraft_color::space::{invert, rgb_to_xyz}; - let e_to_d65 = crate::ocio::bradford([1.0 / 3.0, 1.0 / 3.0], D65); - let rows = |m: [[f64; 3]; 3], sep: &str, end: &str| m.iter().map(|r| format!("{}{sep}{}{sep}{}{end}", r[0], r[1], r[2])).collect::>(); - std::fs::write(dir.join("luts/e_to_d65.spimtx"), rows(e_to_d65, " ", " 0\n").concat()).unwrap(); - let xyz_to_709 = rows(invert(&rgb_to_xyz(REC709, D65)), ", ", ", 0").join(", "); - let text = format!( - r#"ocio_profile_version: 2.4 -search_path: luts -roles: - reference: Linear CIE-XYZ E - scene_linear: Linear Rec.709 - cie_xyz_d65_interchange: XYZ D65 Display -default_view_transform: Standard -view_transforms: - - ! - name: Standard - from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} -displays: - sRGB: - - ! {{name: Standard, colorspace: sRGB}} - - ! {{name: Display, view_transform: Standard, display_colorspace: sRGB Display}} - - ! {{name: Log, colorspace: Log Encoding}} - - ! {{name: Log Brighter, colorspace: Log Encoding, looks: +Brighter}} - - ! {{name: Log Darker, colorspace: Log Encoding, looks: "-Brighter, Missing"}} - - ! {{name: Raw, colorspace: Non-Color}} -display_colorspaces: - - ! - name: XYZ D65 Display - isdata: false - - ! - name: sRGB Display - from_display_reference: ! - children: - - ! {{src: cie_xyz_d65_interchange, dst: Linear Rec.709}} - - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} -colorspaces: - - ! - name: Linear CIE-XYZ E - aliases: [Linear CIE-XYZ I-E] - - ! - name: Linear CIE-XYZ D65 - from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} - - ! - name: Linear Rec.709 - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ I-E, dst: Linear CIE-XYZ D65}} - - ! {{matrix: [{xyz_to_709}, 0, 0, 0, 1]}} - - ! - name: sRGB - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} - - ! - name: Log Encoding - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! {{allocation: lg2, vars: [-12, 12]}} - - ! - name: ACEScg - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear CIE-XYZ D65}} - - ! {{style: "UTILITY - ACES-AP1_to_CIE-XYZ-D65_BFD", direction: inverse}} - - ! - name: Clipped - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! {{min_in_value: 0, min_out_value: 0}} - - ! - name: Graded - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} - - ! - shadows: {{rgb: [0.2, 0.2, 0.2], master: 0.35, start: 0.4, pivot: 0.1}} - - ! - name: Loop - from_scene_reference: ! {{src: Linear CIE-XYZ E, dst: Loop}} - - ! - name: Fan - from_scene_reference: ! - children: - - ! {{src: Linear CIE-XYZ E, dst: Fan}} - - ! {{src: Linear CIE-XYZ E, dst: Fan}} - - ! {{src: Linear CIE-XYZ E, dst: Fan}} - - ! - name: Non-Color - isdata: true -looks: - - ! - name: Brighter - process_space: Log Encoding - transform: ! {{offset: [0.05, 0.05, 0.05]}} -"# - ); - Config::parse(&text, dir).unwrap() - } - - /// The sRGB encoding (IEC 61966-2-1). - fn srgb(v: f64) -> f64 { - if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } - } - - /// #410: with Blender's config, Linear Rec.709 → sRGB got no sRGB curve and a blue cast: - /// ColorSpaceTransform and ExponentWithLinearTransform passed colours through, so the - /// colours stayed in the E-white XYZ reference. Every route to sRGB now encodes greys to - /// neutral sRGB values (0.18 → 0.4614). - #[test] - fn blender_like_config_converts_linear_rec709_to_srgb() { - let dir = temp_dir("blender"); - let c = blender_like(&dir); - assert!(c.unsupported().iter().all(|t| t == "GradingToneTransform"), "{:?}", c.unsupported()); - let sp = |n: &str| c.space(n).unwrap(); - let lin = sp("scene_linear"); - let routes = [ - ("Color Space Transform", c.path(lin, sp("sRGB"))), - ("display-referred sRGB", c.path(lin, sp("sRGB Display"))), - ("v1 view", c.display_path(lin, "sRGB", "Standard")), - ("v2 view", c.display_path(lin, "sRGB", "Display")), - ]; - for (route, x) in &routes { - assert!(x.unsupported_list().is_empty(), "{route}: {:?}", x.unsupported_list()); - for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { - let o = x.apply([v; 3], false); - assert!(close(o, [srgb(v); 3], 2e-5), "{route}: {v} → {o:?}, want {}", srgb(v)); - } - let red = x.apply([1.0, 0.0, 0.0], false); - assert!(close(red, [1.0, 0.0, 0.0], 1e-4), "{route}: red {red:?}"); - // And back. - assert!(close(x.apply([srgb(0.18); 3], true), [0.18; 3], 1e-6), "{route}"); - } - // AllocationTransform lg2 and a look in its process space; data passes through. - let log = |v: f64| (v.log2() + 12.0) / 24.0; - let o = c.display_path(lin, "sRGB", "Log").apply([0.18; 3], false); - assert!(close(o, [log(0.18); 3], 1e-6), "{o:?}"); - let o = c.display_path(lin, "sRGB", "Log Brighter").apply([0.18; 3], false); - assert!(close(o, [log(0.18) + 0.05; 3], 1e-6), "{o:?}"); - let darker = c.display_path(lin, "sRGB", "Log Darker"); - assert!(close(darker.apply([0.18; 3], false), [log(0.18) - 0.05; 3], 1e-6)); - assert_eq!(darker.unsupported_list(), vec!["Look `Missing` (not in the config)".to_string()]); - assert_eq!(c.display_path(lin, "sRGB", "Raw").apply([0.3, 2.0, -1.0], false), [0.3, 2.0, -1.0]); - // BuiltinTransform: the built-in config's Linear Rec.709 → ACEScg. - let want = crate::ocio::Xform::new(&sp_builtin("Linear Rec.709 (sRGB)"), &sp_builtin("ACEScg")).apply([1.0, 0.25, 0.0]); - let got = c.convert(lin, sp("ACEScg"), [1.0, 0.25, 0.0]); - assert!(close(got, want.map(f64::from), 1e-4), "{got:?} vs {want:?}"); - // RangeTransform with only a minimum clamps below and shifts nothing. - let o = c.convert(lin, sp("Clipped"), [-0.5, 0.5, 2.0]); - assert!(close(o, [0.0, 0.5, 2.0], 1e-6), "{o:?}"); - // Unsupported steps are listed (and pass through); loops stop. - assert_eq!(c.path(lin, sp("Graded")).unsupported_list(), vec!["GradingToneTransform".to_string()]); - assert!(c.path(lin, sp("Loop")).unsupported_list()[0].contains("nested")); - let fan = c.path(lin, sp("Fan")); - assert!(!fan.unsupported_list().is_empty()); - assert!(matches!(&fan, Xf::Group(v) if v.len() <= MAX_OPS + 1)); - } - - fn sp_builtin(name: &str) -> Space { - *crate::ocio::SPACES.iter().find(|s| s.name == name).unwrap() - } - - /// ExponentWithLinearTransform is the sRGB curve at gamma 2.4 / offset 0.055, continuous at - /// its break point and inverted exactly; mirror mirrors negatives. - #[test] - fn moncurve_matches_srgb_and_inverts() { - for v in [-0.2, 0.0, 0.002, 0.039, 0.0393, 0.2, 0.5, 1.0, 3.0] { - let lin = moncurve(v, 2.4, 0.055, false, false); - assert!((moncurve(lin, 2.4, 0.055, true, false) - v).abs() < 1e-12, "{v}"); - if v >= 0.0 { - assert!((moncurve(srgb(v), 2.4, 0.055, false, false) - v).abs() < 2e-5, "{v}"); - } - } - let xb = 0.055 / 1.4; - assert!((moncurve(xb - 1e-12, 2.4, 0.055, false, false) - moncurve(xb + 1e-12, 2.4, 0.055, false, false)).abs() < 1e-9); - assert_eq!(moncurve(-0.5, 2.4, 0.055, false, true), -moncurve(0.5, 2.4, 0.055, false, false)); - assert_eq!(moncurve(-0.5, 1.0, 0.0, false, false), 0.0); - } - - #[test] - fn builtin_styles() { - for style in ["IDENTITY", "DISPLAY - CIE-XYZ-D65_to_sRGB", "display - cie-xyz-d65_to_rec.2100-pq", "ACEScc_to_ACES2065-1", "ACEScct_to_ACES2065-1"] { - assert!(builtin(style).is_some(), "{style}"); - } - assert!(builtin("ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0").is_none()); - // D65 white in XYZ is display white. - let xyz = rgb_white_xyz(); - for style in ["DISPLAY - CIE-XYZ-D65_to_sRGB", "DISPLAY - CIE-XYZ-D65_to_REC.1886-REC.709", "DISPLAY - CIE-XYZ-D65_to_DisplayP3"] { - let o = builtin(style).unwrap().apply(xyz, false); - assert!(close(o, [1.0; 3], 1e-6), "{style}: {o:?}"); - } - // PQ: 1.0 = 100 nits → code value ≈ 0.508. - let pq = builtin("DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ").unwrap().apply(xyz, false); - assert!((pq[0] - 0.508).abs() < 1e-3, "{pq:?}"); - // ACEScc and ACEScct of 0.18 (ACES2065-1 grey stays grey). - for (style, code) in [("ACEScc_to_ACES2065-1", 0.413_588_402_492_442_1), ("ACEScct_to_ACES2065-1", 0.413_588_402_492_442_1)] { - let o = builtin(style).unwrap().apply([code; 3], false); - assert!(close(o, [0.18; 3], 1e-6), "{style}: {o:?}"); - } - } - - fn rgb_white_xyz() -> [f64; 3] { - [D65[0] / D65[1], 1.0, (1.0 - D65[0] - D65[1]) / D65[1]] - } -} +mod tests; diff --git a/crates/effects/src/ocio_config/tests.rs b/crates/effects/src/ocio_config/tests.rs new file mode 100644 index 000000000..f4ac71d29 --- /dev/null +++ b/crates/effects/src/ocio_config/tests.rs @@ -0,0 +1,396 @@ +use super::*; + +const CONFIG: &str = r#" +ocio_profile_version: 2 + +name: Test Config # a comment +search_path: "luts:other" +roles: + scene_linear: lin + color_timing: "log2" + +displays: + sRGB: + - ! {name: Standard, colorspace: gamma22} + - ! {name: Raw, colorspace: raw} + +colorspaces: + - ! + name: lin + family: "" + description: | + The reference. + Linear. + isdata: false + + - ! + name: gamma22 + aliases: [g22, "gamma 2.2"] + to_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: half + from_reference: ! {matrix: [0.5, 0, 0, 0, + 0, 0.5, 0, 0, + 0, 0, 0.5, 0, + 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]} + + - ! + name: log2 + from_reference: ! + children: + - ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} + - ! {base: 2} + + - ! + name: affine + to_reference: ! {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse} + + - ! + name: lut + to_reference: ! {src: double.cube, interpolation: linear} + + - ! + name: raw + isdata: true + + - ! + name: weird + to_reference: ! {exposure: 1} +"#; + +fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() < tol) +} + +fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-ocio-{name}-{}", std::process::id())); + std::fs::create_dir_all(dir.join("luts")).unwrap(); + dir +} + +#[test] +fn yaml_subset() { + let y = parse_yaml("a: 1\nb:\n - x\n - {k: [1, 2], s: 'q: r'}\nc: ! {v: 3}\nd:\n- 4\n- 5\n"); + assert_eq!(y.get("a").and_then(Yaml::str), Some("1")); + assert_eq!(y.get("b").unwrap().seq().len(), 2); + assert_eq!(y.get("b").unwrap().seq()[1].get("k").unwrap().nums(), vec![1.0, 2.0]); + assert_eq!(y.get("b").unwrap().seq()[1].get("s").and_then(Yaml::str), Some("q: r")); + assert_eq!(y.get("c").unwrap().tag(), Some("T")); + assert_eq!(y.get("c").unwrap().get("v").and_then(Yaml::str), Some("3")); + assert_eq!(y.get("d").unwrap().seq().len(), 2, "a sequence at the key's indent"); +} + +/// Hostile nesting (flow brackets and block indentation) is cut off instead of exhausting +/// the stack. +#[test] +fn yaml_nesting_is_bounded() { + let flow = format!("a: {}\nb: 1\n", "[".repeat(200_000)); + assert!(matches!(parse_yaml(&flow), Yaml::Map(_))); + let block: String = (0..2_000).map(|i| format!("{}k{i}:\n", " ".repeat(i))).collect(); + assert!(matches!(parse_yaml(&block), Yaml::Map(_))); + let dashes: String = (0..2_000).map(|i| format!("{}- x{i}:\n", " ".repeat(2 * i))).collect(); + let _ = parse_yaml(&dashes); + assert!(Config::parse(&flow, Path::new(".")).is_err()); +} + +#[test] +fn parses_spaces_roles_displays_and_converts() { + let dir = temp_dir("basic"); + // A 1D LUT that doubles (0..1 → 0..2). + std::fs::write(dir.join("luts/double.cube"), "LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n0 0 0\n2 2 2\n").unwrap(); + let c = Config::parse(CONFIG, &dir).unwrap(); + assert_eq!(c.name, "Test Config"); + assert_eq!(c.search_path, vec!["luts", "other"]); + assert_eq!(c.space_names(), vec!["lin", "gamma22", "half", "log2", "affine", "lut", "raw", "weird"]); + // Roles and aliases resolve. + assert_eq!(c.space("scene_linear").unwrap().name, "lin"); + assert_eq!(c.space("G22").unwrap().name, "gamma22"); + assert_eq!(c.space("gamma 2.2").unwrap().name, "gamma22"); + assert_eq!(c.view("sRGB", "Standard").unwrap().1.colorspace, "gamma22"); + let sp = |n: &str| c.space(n).unwrap(); + // gamma22 → lin: decode with the exponent. + let g = c.convert(sp("gamma22"), sp("lin"), [0.5, 0.25, 1.0]); + assert!(close(g, [0.5f64.powf(2.2), 0.25f64.powf(2.2), 1.0], 1e-9), "{g:?}"); + // lin → gamma22: the inverse (only to_reference given). + let back = c.convert(sp("lin"), sp("gamma22"), g); + assert!(close(back, [0.5, 0.25, 1.0], 1e-9), "{back:?}"); + // The display's view is that conversion. + assert!(close(c.display_path(sp("lin"), "sRGB", "Standard").apply(g, false), [0.5, 0.25, 1.0], 1e-9)); + // lin → half: matrix and offset; and back through the inverse matrix. + let h = c.convert(sp("lin"), sp("half"), [0.4, 0.6, 1.0]); + assert!(close(h, [0.3, 0.4, 0.6], 1e-9), "{h:?}"); + assert!(close(c.convert(sp("half"), sp("lin"), h), [0.4, 0.6, 1.0], 1e-9)); + // lin → log2: a group (×2 then log2): 0.5 → 0; 2 → 2. + let l = c.convert(sp("lin"), sp("log2"), [0.5, 2.0, 4.0]); + assert!(close(l, [0.0, 2.0, 3.0], 1e-9), "{l:?}"); + assert!(close(c.convert(sp("log2"), sp("lin"), l), [0.5, 2.0, 4.0], 1e-9)); + // LogAffine (inverse direction as to_reference) round-trips. + let a = c.convert(sp("lin"), sp("affine"), [0.18, 0.5, 1.0]); + let expect = [0.18f64, 0.5, 1.0].map(|v| 0.5 * (2.0 * v + 0.01).log10() + 0.2); + assert!(close(a, expect, 1e-9), "{a:?} vs {expect:?}"); + assert!(close(c.convert(sp("affine"), sp("lin"), a), [0.18, 0.5, 1.0], 1e-9)); + // FileTransform found on the search path. + let f = c.convert(sp("lut"), sp("lin"), [0.25, 0.5, 0.0]); + assert!(close(f, [0.5, 1.0, 0.0], 1e-6), "{f:?}"); + // Data spaces pass through; unsupported transforms are reported. + assert_eq!(c.convert(sp("raw"), sp("gamma22"), [0.3, 0.3, 0.3]), [0.3, 0.3, 0.3]); + assert_eq!(c.unsupported(), vec!["ExposureContrastTransform".to_string()]); + assert_eq!(c.path(sp("lin"), sp("weird")).unsupported_list(), vec!["ExposureContrastTransform".to_string()]); + assert!(c.path(sp("lin"), sp("half")).unsupported_list().is_empty()); + // Loading from a file path (cached), and errors. + std::fs::write(dir.join("config.ocio"), CONFIG).unwrap(); + let loaded = load_config(&dir.join("config.ocio").to_string_lossy()).unwrap(); + assert_eq!(loaded.spaces.len(), 8); + assert!(load_config("/no/such/config.ocio").is_err()); + assert!(Config::parse("just: text\n", &dir).is_err()); +} + +/// The OCIO Color Space and Display Transform effects with Configuration ▸ Custom. +#[test] +fn effects_use_a_custom_config() { + use effectcraft_keyframe::Value; + let cfg = "ocio_profile_version: 1\nroles:\n default: lin\ndisplays:\n Monitor:\n - ! {name: Video, colorspace: g2}\ncolorspaces:\n - !\n name: lin\n - !\n name: g2\n to_reference: ! {value: [2, 2, 2, 1]}\n"; + let img = crate::Image::filled(2, 2, [0.5, 0.25, 1.0, 1.0]); + let fx = |id: &str, extra: &[(&str, Value)]| { + let mut v = vec![("config", Value::Enum(1)), ("configFile", Value::Str(cfg.into()))]; + v.extend(extra.iter().cloned()); + crate::run_fx(id, &v, img.clone(), 0.0, crate::EffectEnv::default()).img.data[0] + }; + let o = fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("default".into()))]); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.0625).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); + // Inverse direction. + let o = fx( + "ec.color.ociocolorspace", + &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("lin".into())), ("direction", Value::Enum(1))], + ); + assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); + // Display ▸ view → its colour space. + let o = fx( + "ec.color.ociodisplay", + &[("sourceName", Value::Str("lin".into())), ("displayName", Value::Str("Monitor".into())), ("viewName", Value::Str("Video".into()))], + ); + assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); + // An unreadable config leaves pixels alone. + let o = crate::run_fx( + "ec.color.ociocolorspace", + &[("config", Value::Enum(1)), ("configFile", Value::Str("/missing.ocio".into()))], + img.clone(), + 0.0, + crate::EffectEnv::default(), + ); + assert_eq!(o.img.data, img.data); +} + +/// A config laid out like Blender 4.x / 5.x's (written for this test): the reference space is +/// CIE XYZ with the E white, the D65 spaces adapt through a `.spimtx` matrix, sRGB is a +/// ColorSpaceTransform plus an ExponentWithLinearTransform, the display-referred spaces meet +/// the scene through the default view transform, and a log space uses an +/// AllocationTransform. +fn blender_like(dir: &Path) -> Config { + use effectcraft_color::space::{invert, rgb_to_xyz}; + let e_to_d65 = crate::ocio::bradford([1.0 / 3.0, 1.0 / 3.0], D65); + let rows = |m: [[f64; 3]; 3], sep: &str, end: &str| m.iter().map(|r| format!("{}{sep}{}{sep}{}{end}", r[0], r[1], r[2])).collect::>(); + std::fs::write(dir.join("luts/e_to_d65.spimtx"), rows(e_to_d65, " ", " 0\n").concat()).unwrap(); + let xyz_to_709 = rows(invert(&rgb_to_xyz(REC709, D65)), ", ", ", 0").join(", "); + let text = format!( + r#"ocio_profile_version: 2.4 +search_path: luts +roles: + reference: Linear CIE-XYZ E + scene_linear: Linear Rec.709 + cie_xyz_d65_interchange: XYZ D65 Display +default_view_transform: Standard +view_transforms: + - ! + name: Standard + from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} +displays: + sRGB: + - ! {{name: Standard, colorspace: sRGB}} + - ! {{name: Display, view_transform: Standard, display_colorspace: sRGB Display}} + - ! {{name: Log, colorspace: Log Encoding}} + - ! {{name: Log Brighter, colorspace: Log Encoding, looks: +Brighter}} + - ! {{name: Log Darker, colorspace: Log Encoding, looks: "-Brighter, Missing"}} + - ! {{name: Raw, colorspace: Non-Color}} +display_colorspaces: + - ! + name: XYZ D65 Display + isdata: false + - ! + name: sRGB Display + from_display_reference: ! + children: + - ! {{src: cie_xyz_d65_interchange, dst: Linear Rec.709}} + - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} +colorspaces: + - ! + name: Linear CIE-XYZ E + aliases: [Linear CIE-XYZ I-E] + - ! + name: Linear CIE-XYZ D65 + from_scene_reference: ! {{src: e_to_d65.spimtx, interpolation: linear}} + - ! + name: Linear Rec.709 + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ I-E, dst: Linear CIE-XYZ D65}} + - ! {{matrix: [{xyz_to_709}, 0, 0, 0, 1]}} + - ! + name: sRGB + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! {{gamma: 2.4, offset: 0.055, direction: inverse}} + - ! + name: Log Encoding + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! {{allocation: lg2, vars: [-12, 12]}} + - ! + name: ACEScg + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear CIE-XYZ D65}} + - ! {{style: "UTILITY - ACES-AP1_to_CIE-XYZ-D65_BFD", direction: inverse}} + - ! + name: Clipped + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! {{min_in_value: 0, min_out_value: 0}} + - ! + name: Graded + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Linear Rec.709}} + - ! + shadows: {{rgb: [0.2, 0.2, 0.2], master: 0.35, start: 0.4, pivot: 0.1}} + - ! + name: Loop + from_scene_reference: ! {{src: Linear CIE-XYZ E, dst: Loop}} + - ! + name: Fan + from_scene_reference: ! + children: + - ! {{src: Linear CIE-XYZ E, dst: Fan}} + - ! {{src: Linear CIE-XYZ E, dst: Fan}} + - ! {{src: Linear CIE-XYZ E, dst: Fan}} + - ! + name: Non-Color + isdata: true +looks: + - ! + name: Brighter + process_space: Log Encoding + transform: ! {{offset: [0.05, 0.05, 0.05]}} +"# + ); + Config::parse(&text, dir).unwrap() +} + +/// The sRGB encoding (IEC 61966-2-1). +fn srgb(v: f64) -> f64 { + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +/// #410: with Blender's config, Linear Rec.709 → sRGB got no sRGB curve and a blue cast: +/// ColorSpaceTransform and ExponentWithLinearTransform passed colours through, so the +/// colours stayed in the E-white XYZ reference. Every route to sRGB now encodes greys to +/// neutral sRGB values (0.18 → 0.4614). +#[test] +fn blender_like_config_converts_linear_rec709_to_srgb() { + let dir = temp_dir("blender"); + let c = blender_like(&dir); + assert!(c.unsupported().iter().all(|t| t == "GradingToneTransform"), "{:?}", c.unsupported()); + let sp = |n: &str| c.space(n).unwrap(); + let lin = sp("scene_linear"); + let routes = [ + ("Color Space Transform", c.path(lin, sp("sRGB"))), + ("display-referred sRGB", c.path(lin, sp("sRGB Display"))), + ("v1 view", c.display_path(lin, "sRGB", "Standard")), + ("v2 view", c.display_path(lin, "sRGB", "Display")), + ]; + for (route, x) in &routes { + assert!(x.unsupported_list().is_empty(), "{route}: {:?}", x.unsupported_list()); + for v in [0.0, 0.001, 0.18, 0.5, 1.0, 4.0] { + let o = x.apply([v; 3], false); + assert!(close(o, [srgb(v); 3], 2e-5), "{route}: {v} → {o:?}, want {}", srgb(v)); + } + let red = x.apply([1.0, 0.0, 0.0], false); + assert!(close(red, [1.0, 0.0, 0.0], 1e-4), "{route}: red {red:?}"); + // And back. + assert!(close(x.apply([srgb(0.18); 3], true), [0.18; 3], 1e-6), "{route}"); + } + // AllocationTransform lg2 and a look in its process space; data passes through. + let log = |v: f64| (v.log2() + 12.0) / 24.0; + let o = c.display_path(lin, "sRGB", "Log").apply([0.18; 3], false); + assert!(close(o, [log(0.18); 3], 1e-6), "{o:?}"); + let o = c.display_path(lin, "sRGB", "Log Brighter").apply([0.18; 3], false); + assert!(close(o, [log(0.18) + 0.05; 3], 1e-6), "{o:?}"); + let darker = c.display_path(lin, "sRGB", "Log Darker"); + assert!(close(darker.apply([0.18; 3], false), [log(0.18) - 0.05; 3], 1e-6)); + assert_eq!(darker.unsupported_list(), vec!["Look `Missing` (not in the config)".to_string()]); + assert_eq!(c.display_path(lin, "sRGB", "Raw").apply([0.3, 2.0, -1.0], false), [0.3, 2.0, -1.0]); + // BuiltinTransform: the built-in config's Linear Rec.709 → ACEScg. + let want = crate::ocio::Xform::new(&sp_builtin("Linear Rec.709 (sRGB)"), &sp_builtin("ACEScg")).apply([1.0, 0.25, 0.0]); + let got = c.convert(lin, sp("ACEScg"), [1.0, 0.25, 0.0]); + assert!(close(got, want.map(f64::from), 1e-4), "{got:?} vs {want:?}"); + // RangeTransform with only a minimum clamps below and shifts nothing. + let o = c.convert(lin, sp("Clipped"), [-0.5, 0.5, 2.0]); + assert!(close(o, [0.0, 0.5, 2.0], 1e-6), "{o:?}"); + // Unsupported steps are listed (and pass through); loops stop. + assert_eq!(c.path(lin, sp("Graded")).unsupported_list(), vec!["GradingToneTransform".to_string()]); + assert!(c.path(lin, sp("Loop")).unsupported_list()[0].contains("nested")); + let fan = c.path(lin, sp("Fan")); + assert!(!fan.unsupported_list().is_empty()); + assert!(matches!(&fan, Xf::Group(v) if v.len() <= MAX_OPS + 1)); +} + +fn sp_builtin(name: &str) -> Space { + *crate::ocio::SPACES.iter().find(|s| s.name == name).unwrap() +} + +/// ExponentWithLinearTransform is the sRGB curve at gamma 2.4 / offset 0.055, continuous at +/// its break point and inverted exactly; mirror mirrors negatives. +#[test] +fn moncurve_matches_srgb_and_inverts() { + for v in [-0.2, 0.0, 0.002, 0.039, 0.0393, 0.2, 0.5, 1.0, 3.0] { + let lin = moncurve(v, 2.4, 0.055, false, false); + assert!((moncurve(lin, 2.4, 0.055, true, false) - v).abs() < 1e-12, "{v}"); + if v >= 0.0 { + assert!((moncurve(srgb(v), 2.4, 0.055, false, false) - v).abs() < 2e-5, "{v}"); + } + } + let xb = 0.055 / 1.4; + assert!((moncurve(xb - 1e-12, 2.4, 0.055, false, false) - moncurve(xb + 1e-12, 2.4, 0.055, false, false)).abs() < 1e-9); + assert_eq!(moncurve(-0.5, 2.4, 0.055, false, true), -moncurve(0.5, 2.4, 0.055, false, false)); + assert_eq!(moncurve(-0.5, 1.0, 0.0, false, false), 0.0); +} + +#[test] +fn builtin_styles() { + for style in ["IDENTITY", "DISPLAY - CIE-XYZ-D65_to_sRGB", "display - cie-xyz-d65_to_rec.2100-pq", "ACEScc_to_ACES2065-1", "ACEScct_to_ACES2065-1"] { + assert!(builtin(style).is_some(), "{style}"); + } + assert!(builtin("ACES-OUTPUT - ACES2065-1_to_CIE-XYZ-D65 - SDR-VIDEO_1.0").is_none()); + // D65 white in XYZ is display white. + let xyz = rgb_white_xyz(); + for style in ["DISPLAY - CIE-XYZ-D65_to_sRGB", "DISPLAY - CIE-XYZ-D65_to_REC.1886-REC.709", "DISPLAY - CIE-XYZ-D65_to_DisplayP3"] { + let o = builtin(style).unwrap().apply(xyz, false); + assert!(close(o, [1.0; 3], 1e-6), "{style}: {o:?}"); + } + // PQ: 1.0 = 100 nits → code value ≈ 0.508. + let pq = builtin("DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ").unwrap().apply(xyz, false); + assert!((pq[0] - 0.508).abs() < 1e-3, "{pq:?}"); + // ACEScc and ACEScct of 0.18 (ACES2065-1 grey stays grey). + for (style, code) in [("ACEScc_to_ACES2065-1", 0.413_588_402_492_442_1), ("ACEScct_to_ACES2065-1", 0.413_588_402_492_442_1)] { + let o = builtin(style).unwrap().apply([code; 3], false); + assert!(close(o, [0.18; 3], 1e-6), "{style}: {o:?}"); + } +} + +fn rgb_white_xyz() -> [f64; 3] { + [D65[0] / D65[1], 1.0, (1.0 - D65[0] - D65[1]) / D65[1]] +}