EXR export: write the output space's chromaticities attribute (#3038)

* EXR export: write the output space's chromaticities attribute (#3037)

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

* EXR chromaticities: tests for unmanaged and PQ ProEXR, every space once

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

* EXR export: note why the comp background is added before decoding (review point)

CodeRabbit asked to convert Comp.background to the output space before
compositing. Every output path (Image::to_rgba8_over) adds the background to
the output-encoded frame as it is, so the EXR now matches the other outputs;
converting would make the EXR colour differ from the PNG of the same comp.
Comment only.

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

* Format the merged code with rustfmt

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-11-06 17:19:05 +01:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent ee7c51740d
commit ae068ff931
9 changed files with 150 additions and 12 deletions
+7 -2
View File
@@ -160,7 +160,8 @@ pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec
// The composite and every layer without the HDR step, decoded with the output space's
// curve (linear light in the output space's primaries).
let (full, layers) = isolate(s, cid, t, scale, true)?;
let dec = effectcraft_render::color::Pipe::of(&s.project.settings).output_linear();
let pipe = effectcraft_render::color::Pipe::of(&s.project.settings);
let dec = pipe.output_linear();
let (w, h) = (full.width as usize, full.height as usize);
let planes = |img: &effectcraft_raster::Image| -> [Vec<f32>; 4] {
let mut out: [Vec<f32>; 4] = Default::default();
@@ -192,7 +193,11 @@ pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec
}
let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, AnyChannels::sort(channels.into()));
let mut buf = std::io::Cursor::new(Vec::new());
Image::from_layer(layer).write().to_buffered(&mut buf).map_err(|e| EngineError::Other(format!("EXR encoding failed: {e}")))?;
let mut image = Image::from_layer(layer);
// The primaries and white the values are in (the output space's), for Nuke, AE and Blender.
let [red, green, blue, white] = pipe.output_linear_space().exr_chromaticities().map(|[x, y]| Vec2(x, y));
image.attributes.chromaticities = Some(exr::meta::attribute::Chromaticities { red, green, blue, white });
image.write().to_buffered(&mut buf).map_err(|e| EngineError::Other(format!("EXR encoding failed: {e}")))?;
Ok((buf.into_inner(), names, full.width, full.height))
}
+25
View File
@@ -358,3 +358,28 @@ fn proexr_layers_are_not_tone_mapped() {
assert!((at(n) - want).abs() <= 1e-4 * want, "{n}: {} vs {want}", at(n));
}
}
/// The `chromaticities` attribute of the ProEXR file `path`: red, green, blue, white (CIE xy).
fn proexr_chromaticities(s: &mut Session, name: &str) -> Option<[[f32; 2]; 4]> {
let path = out_dir(name).join("frame.exr").to_string_lossy().to_string();
s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
let meta = exr::meta::MetaData::read_from_buffered(std::io::BufReader::new(std::fs::File::open(&path).unwrap()), false).unwrap();
meta.headers[0].shared_attributes.chromaticities.map(|c| [c.red, c.green, c.blue, c.white].map(|v| [v.0, v.1]))
}
/// ProEXR names the primaries and white of the output space (default: Rec. 709 / sRGB).
#[test]
fn proexr_writes_the_output_spaces_chromaticities() {
let d65 = [0.3127, 0.3290];
let mut s = frame();
assert_eq!(proexr_chromaticities(&mut s, "frame-exr-chroma-default"), Some([[0.64, 0.33], [0.30, 0.60], [0.15, 0.06], d65]));
s.execute("file.projectSettings", json!({"workingSpace": "srgb", "outputSpace": "p3"})).unwrap();
assert_eq!(proexr_chromaticities(&mut s, "frame-exr-chroma-p3"), Some([[0.680, 0.320], [0.265, 0.690], [0.150, 0.060], d65]));
s.execute("file.projectSettings", json!({"outputSpace": "rec2020"})).unwrap();
assert_eq!(proexr_chromaticities(&mut s, "frame-exr-chroma-2020"), Some([[0.708, 0.292], [0.170, 0.797], [0.131, 0.046], d65]));
s.execute("file.projectSettings", json!({"outputSpace": "rec2100pq"})).unwrap();
assert_eq!(proexr_chromaticities(&mut s, "frame-exr-chroma-pq"), Some([[0.708, 0.292], [0.170, 0.797], [0.131, 0.046], d65]));
// Unmanaged: the output space is ignored, the Working Gamma's curve (Rec. 709 primaries) decodes.
s.execute("file.projectSettings", json!({"workingSpace": "none", "outputSpace": "p3"})).unwrap();
assert_eq!(proexr_chromaticities(&mut s, "frame-exr-chroma-unmanaged"), Some([[0.64, 0.33], [0.30, 0.60], [0.15, 0.06], d65]));
}