Files
effectcraft/crates/export/tests/it/exr_scene_linear.rs
T
FredAppsandClaude Opus 5.5 4465c0ea10 EXR golden test: compare samples near, not bit for bit (#3181)
* EXR golden test: hash samples near, not bit for bit (temporary: print the new goldens)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* EXR golden test: the new near hashes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:23:50 +01:00

481 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! OpenEXR output holds the comp's values in linear light without Project Settings ▸ HDR
//! (compand / tone map), and decodes with the output space's own curve; 8-bit writers keep the
//! HDR step and their exact bytes.
use std::path::{Path, PathBuf};
use effectcraft_color::{ColorSpace, Conversion, Label};
use effectcraft_export::render_queue::{Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan};
use effectcraft_export::{Job, export};
use effectcraft_project::{BitDepth, Comp, HdrMode, ItemId, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_render::NoFootage;
use effectcraft_time::{FrameRate, Tick};
const W: u32 = 48;
const H: u32 = 16;
/// Three nested bands, outermost first: in range, inside the compand knee, over range.
const BANDS: [[f32; 3]; 3] = [[0.2, 0.5, 0.9], [0.85, 0.9, 0.95], [4.0, 2.0, 0.5]];
fn out_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("effectcraft-exr-scene-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("mkdir");
d
}
/// A one-frame comp of [`BANDS`] (full width, 2/3, 1/3, centred) with the given colour settings.
fn project(depth: BitDepth, hdr: HdrMode, ws: Option<ColorSpace>, linearize: bool, out: Option<ColorSpace>) -> (Project, ItemId) {
let mut p = Project::default();
p.settings.bit_depth = depth;
p.settings.hdr = hdr;
p.settings.working_space = ws;
p.settings.linearize = linearize;
p.settings.output_space = out;
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(0.1));
comp.background = [0.0, 0.0, 0.0];
for (i, color) in BANDS.into_iter().enumerate() {
let w = W - W / 3 * i as u32;
let sid = p.add_item(&format!("Band {i}"), Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: H, pixel_aspect: 1.0 }));
let l = build::layer(&mut p, &comp, &format!("Band {i}"), LayerSource::Solid { item: sid }, (w, H), None);
comp.layers.insert(0, l);
}
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
/// A comp builder: bit depth, HDR mode, working space, Linearize Working Space, output space.
type Build = fn(BitDepth, HdrMode, Option<ColorSpace>, bool, Option<ColorSpace>) -> (Project, ItemId);
/// The half-opacity layer of [`half_project`]: over range in red.
const HALF: [f32; 3] = [4.0, 0.6, 0.3];
/// A one-frame comp over a coloured background whose only layer, [`HALF`] at 50% opacity,
/// covers the left two thirds: semi-transparent and transparent pixels (the premultiplied and
/// RGB-over-background paths of the writers).
fn half_project(depth: BitDepth, hdr: HdrMode, ws: Option<ColorSpace>, linearize: bool, out: Option<ColorSpace>) -> (Project, ItemId) {
let mut p = Project::default();
p.settings.bit_depth = depth;
p.settings.hdr = hdr;
p.settings.working_space = ws;
p.settings.linearize = linearize;
p.settings.output_space = out;
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(0.1));
comp.background = [0.2, 0.3, 0.4];
let w = W / 3 * 2;
let sid = p.add_item("Half", Label::Red, None, ItemKind::Solid(Solid { color: HALF, width: w, height: H, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Half", LayerSource::Solid { item: sid }, (w, H), None);
l.props.prop_mut("transform/position").expect("position").value = effectcraft_keyframe::Value::Vec3([w as f64 / 2.0, H as f64 / 2.0, 0.0]);
l.props.prop_mut("transform/opacity").expect("opacity").value = effectcraft_keyframe::Value::Scalar(50.0);
comp.layers.push(l);
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
/// Export the comp's one frame as a `fmt` sequence; the frame file's path.
fn render(p: &Project, cid: ItemId, fmt: OutputFormat, channels: Channels, dir: &Path, ext: &str) -> PathBuf {
let mut om = OutputModule::for_format(fmt);
om.channels = channels;
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
let path = dir.join(format!("f_[#####].{ext}")).to_string_lossy().to_string();
let job = Job {
project: p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: &om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
export(&job, &mut |_| true).expect("export");
dir.join(format!("f_00000.{ext}"))
}
fn fnv(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ *b as u64).wrapping_mul(0x0100_0000_01b3))
}
const DEPTHS: [BitDepth; 3] = [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32];
const MODES: [HdrMode; 3] = [HdrMode::Clip, HdrMode::Compand, HdrMode::ToneMap];
const STILLS: [(OutputFormat, Channels, &str); 3] = [
(OutputFormat::PngSequence, Channels::Rgba, "png"),
(OutputFormat::TiffSequence, Channels::Rgb, "tif"),
(OutputFormat::TargaSequence, Channels::Rgba, "tga"),
];
/// The bytes of every 8-bit still for each bit depth × HDR mode, in a managed linear sRGB
/// project with a Display P3 output.
fn still_hashes(build: Build) -> Vec<u64> {
let mut out = vec![];
for depth in DEPTHS {
for mode in MODES {
let (p, cid) = build(depth, mode, Some(ColorSpace::Srgb), true, Some(ColorSpace::DisplayP3));
for (fmt, ch, ext) in STILLS {
let d = out_dir(&format!("golden-{depth:?}-{mode:?}-{ext}"));
let bytes = std::fs::read(render(&p, cid, fmt, ch, &d, ext)).expect("read");
out.push(fnv(&bytes));
let _ = std::fs::remove_dir_all(&d);
}
}
}
out
}
/// [`still_hashes`] before float outputs skipped the HDR step: PNG, TIFF and Targa (8-bit
/// writers, which keep compand / tone mapping) are byte for byte the same.
const STILL_GOLDEN: [u64; 27] = [
0xd0cf67f45d6b6b68,
0x712d42f3e204cbdb,
0x021dc457f259eb87,
0x88a16b1ca2db8805,
0xc6fb221b89179bdb,
0x965636561aaabb87,
0x85c831b7694662db,
0x8875a5c5232a33db,
0x42d8f3c110bd0987,
0xd555a6850fe66f04,
0x920b49ee0f9900e4,
0x94e4f43742f2c8ce,
0xab0792254da43348,
0x647a60c0a33237ec,
0x65cba80bc3ecff08,
0x0726be1b60eb7276,
0xffd0315633cb88db,
0x4f6a13131fb7a175,
0x07a706a7e100b51d,
0x718600eaa65a6f80,
0x53489230b732f638,
0x13024d498aa2552c,
0x3a3dae30c9e25f83,
0xc3191a3e860aa0c5,
0xe5d7d08ecc3ddece,
0x29a748f729fe5c8a,
0x78d3b9aa9e107774,
];
#[test]
fn eight_bit_stills_are_unchanged() {
assert_eq!(still_hashes(project), STILL_GOLDEN);
assert_eq!(still_hashes(half_project), HALF_STILL_GOLDEN);
}
/// [`still_hashes`] of [`half_project`] before: premultiplied and RGB-over-background stills.
const HALF_STILL_GOLDEN: [u64; 27] = [
0x3abd611ee63b7edd,
0x51e7ec0426773cdb,
0xbee34abe9f0a3d87,
0x554c03dba915d290,
0xe98490fc826a7adb,
0x0a0e77927bf1a187,
0x4f1c076634822d50,
0xe790f474e05b3adb,
0xe517c3136471a187,
0x07cd5e73765539f9,
0x68e51bbd8d442a64,
0x844a5f23a9cc9a3d,
0xc302b2b86f4cdd70,
0xb7247795d204a2c5,
0x3b18867537c69265,
0x9a5f7679bb5a2399,
0x868b729dcc804656,
0x2e0bc64a3fb562f0,
0x6dc9e1a1eac1220d,
0x6e101cd849e78f32,
0x7b7a2a776f73d70b,
0x6b3244f808c65d4e,
0xbfa1b35fada2c77d,
0x8c3a31415ed47e33,
0xac787507caf1c896,
0x4a38297060de19ff,
0x300d6e4222eaa5b5,
];
/// The decoded samples of a still EXR frame, row-major RGBA.
fn exr_samples(path: &Path) -> Vec<f32> {
image::open(path).expect("exr").to_rgba32f().into_raw()
}
fn bits_hash(v: &[f32]) -> u64 {
fnv(&v.iter().flat_map(|x| x.to_bits().to_le_bytes()).collect::<Vec<u8>>())
}
/// [`bits_hash`] of the samples rounded to 15 mantissa bits. 32 bpc samples go through `powf`, and
/// the last bits of its result differ between platforms' math libraries (macOS on Apple silicon).
fn near_hash(v: &[f32]) -> u64 {
bits_hash(&v.iter().map(|x| f32::from_bits(x.to_bits().wrapping_add(0x80) & !0xff)).collect::<Vec<f32>>())
}
/// Clip-mode EXR samples with an sRGB output, for each colour setup × bit depth × channels.
fn exr_clip_hashes(build: Build, channels: &[Channels]) -> Vec<u64> {
let mut out = vec![];
for (ws, linearize) in [(None, false), (Some(ColorSpace::Srgb), false), (Some(ColorSpace::Srgb), true), (Some(ColorSpace::AcesCg), true)] {
for depth in DEPTHS {
let (p, cid) = build(depth, HdrMode::Clip, ws, linearize, None);
for &ch in channels {
let d = out_dir(&format!("clip-{ws:?}-{linearize}-{depth:?}-{ch:?}"));
out.push(near_hash(&exr_samples(&render(&p, cid, OutputFormat::ExrSequence, ch, &d, "exr"))));
let _ = std::fs::remove_dir_all(&d);
}
}
}
out
}
/// [`exr_clip_hashes`] before: Clip-mode EXRs with an sRGB output keep every sample.
const EXR_CLIP_GOLDEN: [u64; 36] = [
0x008b8f887b9f6d25,
0x008b8f887b9f6d25,
0x7d40ad9df8600325,
0x5d55eb1dddaa5f25,
0x5d55eb1dddaa5f25,
0x7d40ad9df8600325,
0x41734aa4aa58c525,
0x41734aa4aa58c525,
0x7d40ad9df8600325,
0x008b8f887b9f6d25,
0x008b8f887b9f6d25,
0x7d40ad9df8600325,
0x5d55eb1dddaa5f25,
0x5d55eb1dddaa5f25,
0x7d40ad9df8600325,
0x41734aa4aa58c525,
0x41734aa4aa58c525,
0x7d40ad9df8600325,
0x6f6623e2a3f91d25,
0x6f6623e2a3f91d25,
0x7d40ad9df8600325,
0x5d55eb1dddaa5f25,
0x5d55eb1dddaa5f25,
0x7d40ad9df8600325,
0x41734aa4aa58c525,
0x41734aa4aa58c525,
0x7d40ad9df8600325,
0x283ae54f8c799325,
0x283ae54f8c799325,
0x7d40ad9df8600325,
0xa33e57ad0ed75f25,
0xa33e57ad0ed75f25,
0x7d40ad9df8600325,
0xb394d882f55d9d25,
0xb394d882f55d9d25,
0x7d40ad9df8600325,
];
#[test]
fn exr_clip_with_srgb_output_is_unchanged() {
// The bands are opaque everywhere, so the Alpha-only files (alpha 1.0) are unchanged too.
assert_eq!(exr_clip_hashes(project, &[Channels::Rgba, Channels::Rgb, Channels::Alpha]), EXR_CLIP_GOLDEN);
assert_eq!(exr_clip_hashes(half_project, &[Channels::Rgba, Channels::Rgb]), HALF_EXR_CLIP_GOLDEN);
}
/// [`exr_clip_hashes`] of [`half_project`] before (RGBA premultiplied, RGB over the background).
const HALF_EXR_CLIP_GOLDEN: [u64; 24] = [
0xff7aaead9ab01325,
0x3acad0d73898c925,
0x43f9a05a72195325,
0x3ecc7469546f8125,
0x53f3d9bb0c539325,
0xc39f1146174d9525,
0xff7aaead9ab01325,
0x3acad0d73898c925,
0x43f9a05a72195325,
0x3ecc7469546f8125,
0x53f3d9bb0c539325,
0xc39f1146174d9525,
0x2f1672447511f325,
0x24cd9261bb6a2125,
0x7860797209347f25,
0xfb0c8f2dcb2e5125,
0x53f3d9bb0c539325,
0xc39f1146174d9525,
0xad649b2be4ec3f25,
0x604f764e18b99925,
0x65d354406ce81b25,
0x4d30fabdbcdbe125,
0xf125e2282bdd9725,
0x00276de880e5e125,
];
/// An Alpha-only EXR holds the alpha (coverage) as it is in every output space: no transfer
/// curve is decoded from it (it used to be sRGB-decoded, 0.5 → 0.214).
#[test]
fn exr_alpha_only_is_raw_alpha() {
for out in [None, Some(ColorSpace::Rec709), Some(ColorSpace::Rec2100Pq), Some(ColorSpace::AcesCg)] {
for ws in [None, Some(ColorSpace::Srgb)] {
let (p, cid) = half_project(BitDepth::Bpc32, HdrMode::Clip, ws, false, out);
let d = out_dir(&format!("alpha-{ws:?}-{out:?}"));
let v = exr_samples(&render(&p, cid, OutputFormat::ExrSequence, Channels::Alpha, &d, "exr"));
let _ = std::fs::remove_dir_all(&d);
let at = |x: u32| &v[((H / 2 * W + x) * 4) as usize..][..4];
assert_eq!(at(2), [0.5, 0.5, 0.5, 1.0], "{ws:?} {out:?} covered");
assert_eq!(at(W - 2), [0.0, 0.0, 0.0, 1.0], "{ws:?} {out:?} transparent");
}
}
}
/// Compand and tone mapping leave EXR output alone: the file holds the Clip mode's samples, for
/// every bit depth, channel set and colour setup (wide working spaces, a Display P3 output).
#[test]
fn exr_ignores_compand_and_tone_mapping() {
let setups = [
(Some(ColorSpace::Srgb), true, None),
(Some(ColorSpace::Srgb), false, Some(ColorSpace::DisplayP3)),
(Some(ColorSpace::AcesCg), true, None),
(Some(ColorSpace::Rec2020), true, Some(ColorSpace::Rec709)),
];
for (ws, linearize, out) in setups {
for depth in DEPTHS {
for (ch, (name, build)) in
[Channels::Rgba, Channels::Rgb].into_iter().flat_map(|c| [(c, ("bands", project as Build)), (c, ("half", half_project as Build))])
{
let samples = |mode: HdrMode| {
let (p, cid) = build(depth, mode, ws, linearize, out);
let d = out_dir(&format!("modes-{name}-{ws:?}-{linearize}-{out:?}-{depth:?}-{ch:?}-{mode:?}"));
let v = exr_samples(&render(&p, cid, OutputFormat::ExrSequence, ch, &d, "exr"));
let _ = std::fs::remove_dir_all(&d);
v
};
let clip = samples(HdrMode::Clip);
for mode in [HdrMode::Compand, HdrMode::ToneMap] {
assert!(samples(mode) == clip, "{name} {ws:?} {linearize} {out:?} {depth:?} {ch:?} {mode:?}");
}
}
}
}
}
/// The centre pixel (the innermost band) of a one-frame EXR render.
fn exr_centre(p: &Project, cid: ItemId, name: &str) -> [f32; 4] {
let d = out_dir(name);
let v = exr_samples(&render(p, cid, OutputFormat::ExrSequence, Channels::Rgba, &d, "exr"));
let _ = std::fs::remove_dir_all(&d);
let o = ((H / 2 * W + W / 2) * 4) as usize;
[v[o], v[o + 1], v[o + 2], v[o + 3]]
}
fn assert_near(got: [f32; 4], want: [f32; 3], what: &str) {
for i in 0..3 {
assert!((got[i] - want[i]).abs() <= 1e-4 * want[i].abs().max(1.0), "{what}: channel {i}: got {got:?}, want {want:?}");
}
assert_eq!(got[3], 1.0, "{what}: {got:?}");
}
/// In a 32 bpc linear project with compand or tone mapping, the EXR holds the comp's scene-linear
/// values in the output space's primaries: 4.0 stays 4.0. (Solid colours are authored in the
/// working space's encoding; ACES spaces are linear.)
#[test]
fn exr_holds_scene_linear_values_in_output_primaries() {
let [_, _, band] = BANDS;
for mode in [HdrMode::Compand, HdrMode::ToneMap] {
let cases = [
// ACEScg in and out: nothing converts.
(ColorSpace::AcesCg, ColorSpace::AcesCg, band),
// ACEScg working space, Display P3 output: the primaries convert, the values stay linear.
(ColorSpace::AcesCg, ColorSpace::DisplayP3, Conversion::new(ColorSpace::AcesCg, true, ColorSpace::DisplayP3, true).expect("matrix").apply(band)),
// Linearized Rec. 2020 working space, sRGB output.
(ColorSpace::Rec2020, ColorSpace::Srgb, Conversion::new(ColorSpace::Rec2020, false, ColorSpace::Srgb, true).expect("matrix").apply(band)),
// Linearized sRGB in and out.
(ColorSpace::Srgb, ColorSpace::Srgb, band.map(effectcraft_color::srgb_to_linear)),
];
for (ws, out, want) in cases {
let (p, cid) = project(BitDepth::Bpc32, mode, Some(ws), true, Some(out));
let what = format!("{mode:?} {ws:?} to {out:?}");
assert_near(exr_centre(&p, cid, &format!("lin-{ws:?}-{out:?}-{mode:?}")), want.map(|v| v.max(0.0)), &what);
}
}
let (p, cid) = project(BitDepth::Bpc32, HdrMode::ToneMap, Some(ColorSpace::AcesCg), true, Some(ColorSpace::AcesCg));
assert_eq!(exr_centre(&p, cid, "lin-exact")[..3], band, "4.0 is exactly 4.0");
}
/// Rec. 2100 PQ and HLG outputs (no HDR step there in any mode) are decoded with their own
/// curves: linear light in Rec. 2020 primaries, 1.0 = reference white (203 cd/m²).
#[test]
fn exr_decodes_pq_and_hlg_outputs() {
let [_, _, band] = BANDS;
let want = Conversion::new(ColorSpace::AcesCg, true, ColorSpace::Rec2020, true).expect("matrix").apply(band).map(|v| v.max(0.0));
for out in [ColorSpace::Rec2100Pq, ColorSpace::Rec2100Hlg] {
for mode in MODES {
let (p, cid) = project(BitDepth::Bpc32, mode, Some(ColorSpace::AcesCg), true, Some(out));
let got = exr_centre(&p, cid, &format!("hdr-out-{out:?}-{mode:?}"));
for i in 0..3 {
assert!((got[i] - want[i]).abs() <= 1e-3 * want[i].max(1.0), "{out:?} {mode:?} channel {i}: got {got:?}, want {want:?}");
}
}
}
}
/// The EXR is decoded with the output space's curve, not always sRGB's: a Rec. 709 output (pure
/// 2.4 power curve) and an unmanaged project at Working Gamma 2.4 give back the linear values.
#[test]
fn exr_decodes_with_the_output_curve() {
let [_, _, band] = BANDS;
// A linearized sRGB working space (same primaries as Rec. 709): the authored colour, decoded.
let (p, cid) = project(BitDepth::Bpc32, HdrMode::Clip, Some(ColorSpace::Srgb), true, Some(ColorSpace::Rec709));
assert_near(exr_centre(&p, cid, "curve-709"), band.map(effectcraft_color::srgb_to_linear), "Rec. 709 output");
// Unmanaged at 2.4: the solid's colour is encoded with the 2.4 power curve.
let (mut p, cid) = project(BitDepth::Bpc32, HdrMode::Clip, None, false, None);
p.settings.working_gamma = 2.4;
assert_near(exr_centre(&p, cid, "curve-gamma24"), band.map(|v| v.powf(2.4)), "Working Gamma 2.4");
// Working Gamma 2.2 keeps the sRGB curve.
let (p, cid) = project(BitDepth::Bpc32, HdrMode::Clip, None, false, None);
assert_near(exr_centre(&p, cid, "curve-gamma22"), band.map(effectcraft_color::srgb_to_linear), "Working Gamma 2.2");
}
/// The `chromaticities` attribute of an EXR file: red, green, blue, white (CIE xy), or `None`.
fn chromaticities(path: &Path) -> Option<[[f32; 2]; 4]> {
let bytes = std::fs::read(path).expect("read");
let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false).expect("exr header");
meta.headers[0].shared_attributes.chromaticities.map(|c| [c.red, c.green, c.blue, c.white].map(|v| [v.0, v.1]))
}
/// The primaries (red, green, blue, white) the standards publish for each output space, typed in
/// here rather than read from the colour crate: Rec. 709 / sRGB, Rec. 2020 (also Rec. 2100 PQ and
/// HLG), Display P3 (D65), ACEScg (AP1) and ACES2065-1 (AP0) with the ACES white.
const D65: [f32; 2] = [0.3127, 0.3290];
const ACES_WHITE: [f32; 2] = [0.32168, 0.33767];
const REC709: [[f32; 2]; 4] = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06], D65];
const REC2020: [[f32; 2]; 4] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046], D65];
const P3: [[f32; 2]; 4] = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060], D65];
const AP1: [[f32; 2]; 4] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044], ACES_WHITE];
const AP0: [[f32; 2]; 4] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770], ACES_WHITE];
/// A sequence frame names the output space's primaries and white, for every space a project knows.
#[test]
fn exr_writes_the_output_spaces_chromaticities() {
let table = [
(ColorSpace::Srgb, REC709),
(ColorSpace::Rec709, REC709),
(ColorSpace::Rec2020, REC2020),
(ColorSpace::DisplayP3, P3),
(ColorSpace::AcesCg, AP1),
(ColorSpace::Aces2065, AP0),
(ColorSpace::Rec2100Pq, REC2020),
(ColorSpace::Rec2100Hlg, REC2020),
];
assert!(ColorSpace::ALL.iter().all(|c| table.iter().filter(|(t, _)| t == c).count() == 1), "every space once");
for (out, want) in table {
for ch in [Channels::Rgba, Channels::Rgb] {
let (p, cid) = project(BitDepth::Bpc32, HdrMode::Clip, Some(ColorSpace::Srgb), true, Some(out));
let d = out_dir(&format!("chroma-{out:?}-{ch:?}"));
let f = render(&p, cid, OutputFormat::ExrSequence, ch, &d, "exr");
assert_eq!(chromaticities(&f), Some(want), "{out:?} {ch:?}");
}
}
}
/// The default project (no output space chosen, managed or not) writes the standard Rec. 709 /
/// sRGB chromaticities; an unmanaged project ignores the output space (its frame is decoded with
/// the Working Gamma's curve, Rec. 709 primaries).
#[test]
fn exr_default_is_rec709_chromaticities() {
for (name, ws, out) in [("default", None, None), ("managed", Some(ColorSpace::Srgb), None), ("unmanaged-p3", None, Some(ColorSpace::DisplayP3))] {
let (p, cid) = project(BitDepth::Bpc32, HdrMode::Clip, ws, false, out);
let d = out_dir(&format!("chroma-{name}"));
let f = render(&p, cid, OutputFormat::ExrSequence, Channels::Rgba, &d, "exr");
assert_eq!(chromaticities(&f), Some(REC709), "{name}");
}
}