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 KleinandClaude Sonnet 5.5 authored and GitHub committed 2026-11-06 17:19:05 +01:00
1 parent ee7c51740d
commit ae068ff931
9 files changed
+150 -12

No files matched your search

+8
View File
@@ -210,6 +210,14 @@ impl ColorSpace {
}
}
/// The primaries and white as the OpenEXR `chromaticities` attribute stores them: red, green,
/// blue, white (CIE xy). Every space has a defined mapping: the PQ and HLG spaces are BT.2020
/// primaries with D65, the ACES spaces carry the ACES white.
pub fn exr_chromaticities(self) -> [[f32; 2]; 4] {
let [r, g, b] = self.primaries();
[r, g, b, self.white()].map(|c| c.map(|v| v as f32))
}
/// The transfer curve.
pub fn curve(self) -> Curve {
match self {
+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]));
}
+35 -4
View File
@@ -13,6 +13,7 @@
use std::io::{Seek, Write};
use effectcraft_project::render_queue::{ExrBitDepth, ExrCompression};
use exr::meta::attribute::Chromaticities;
use exr::prelude::{Blocks, Compression, Encoding, Image, Layer, LayerAttributes, LineOrder, SpecificChannels, Vec2, WritableImage, f16};
/// The largest finite half float.
@@ -48,7 +49,20 @@ fn encoding(c: ExrCompression) -> Encoding {
/// Write `w`×`h` RGBA pixels (`data`: four samples per pixel as they are to be written,
/// premultiplied, rows top to bottom) as one OpenEXR frame, its blocks compressed in parallel as before. A pixel `data`
/// doesn't cover is written as transparent black.
pub fn write_rgba<W: Write + Seek>(file: &mut W, data: &[f32], (w, h): (usize, usize), (c, depth): (ExrCompression, ExrBitDepth)) -> exr::error::UnitResult {
///
/// `chroma` is the primaries and white the samples are in (red, green, blue, white as CIE xy), written as the
/// file's `chromaticities` attribute for Nuke, After Effects and Blender; `None` writes no attribute.
pub fn write_rgba<W: Write + Seek>(
file: &mut W,
data: &[f32],
(w, h): (usize, usize),
(c, depth): (ExrCompression, ExrBitDepth),
chroma: Option<[[f32; 2]; 4]>,
) -> exr::error::UnitResult {
let chroma = chroma.map(|[red, green, blue, white]| {
let v = |[x, y]: [f32; 2]| Vec2(x, y);
Chromaticities { red: v(red), green: v(green), blue: v(blue), white: v(white) }
});
let row = w.saturating_mul(4);
let px = move |p: Vec2<usize>| -> [f32; 4] {
let o = p.y().saturating_mul(row).saturating_add(p.x().saturating_mul(4));
@@ -59,7 +73,7 @@ pub fn write_rgba<W: Write + Seek>(file: &mut W, data: &[f32], (w, h): (usize, u
};
match depth {
ExrBitDepth::Float => {
let image = Image::from_layer(Layer::new(
let mut image = Image::from_layer(Layer::new(
(w, h),
LayerAttributes::default(),
encoding(c),
@@ -68,10 +82,11 @@ pub fn write_rgba<W: Write + Seek>(file: &mut W, data: &[f32], (w, h): (usize, u
(r, g, b, a)
}),
));
image.attributes.chromaticities = chroma;
image.write().to_buffered(file)
}
ExrBitDepth::Half => {
let image = Image::from_layer(Layer::new(
let mut image = Image::from_layer(Layer::new(
(w, h),
LayerAttributes::default(),
encoding(c),
@@ -80,6 +95,7 @@ pub fn write_rgba<W: Write + Seek>(file: &mut W, data: &[f32], (w, h): (usize, u
(r, g, b, a)
}),
));
image.attributes.chromaticities = chroma;
image.write().to_buffered(file)
}
}
@@ -118,7 +134,7 @@ mod tests {
fn write(data: &[f32], size: (usize, usize), c: ExrCompression, depth: ExrBitDepth) -> Vec<u8> {
let mut buf = Cursor::new(Vec::new());
write_rgba(&mut buf, data, size, (c, depth)).unwrap();
write_rgba(&mut buf, data, size, (c, depth), None).unwrap();
buf.into_inner()
}
@@ -234,4 +250,19 @@ mod tests {
assert_eq!(&got[..4], &[1.0, 0.5, 0.25, 1.0]);
assert!(got[4..].iter().all(|v| *v == 0.0));
}
/// The chromaticities attribute is written when given and absent otherwise.
#[test]
fn chromaticities_are_written_only_when_given() {
let rec709 = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06], [0.3127, 0.3290]];
for depth in [ExrBitDepth::Float, ExrBitDepth::Half] {
let mut with = Cursor::new(Vec::new());
write_rgba(&mut with, &[0.5; 4], (1, 1), (ExrCompression::Zip, depth), Some(rec709)).unwrap();
let meta = exr::meta::MetaData::read_from_buffered(Cursor::new(with.into_inner()), false).unwrap();
let got = meta.headers[0].shared_attributes.chromaticities.map(|c| [c.red, c.green, c.blue, c.white].map(|v| [v.0, v.1]));
assert_eq!(got, Some(rec709));
let meta = exr::meta::MetaData::read_from_buffered(Cursor::new(write(&[0.5; 4], (1, 1), ExrCompression::Zip, depth)), false).unwrap();
assert!(meta.headers[0].shared_attributes.chromaticities.is_none());
}
}
}
+9 -3
View File
@@ -319,7 +319,8 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
// Colour is floored at 0 but not capped at 1: a 32 bpc frame's over-range values survive (#339).
// The frame was rendered without the HDR step (`RenderOpts::skip_hdr_step`) and is
// decoded with the output space's curve, in the output space's primaries.
let dec = effectcraft_render::color::Pipe::of(&cx.project.settings).output_linear();
let pipe = effectcraft_render::color::Pipe::of(&cx.project.settings);
let dec = pipe.output_linear();
let lin3 = |c: [f32; 3]| dec.apply(c.map(|v| v.max(0.0)));
let mut data = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
@@ -334,6 +335,8 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
(c, a)
}
Channels::Rgb => {
// The background is added to the output-encoded frame as it is, as every other output
// does (`Image::to_rgba8_over`), then decoded with the frame: same colour as a PNG.
let k = 1.0 - p[3].clamp(0.0, 1.0);
(lin3([0, 1, 2].map(|i| p[i] + comp.background[i] * k)), 1.0)
}
@@ -341,9 +344,12 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
data.extend_from_slice(&[c[0], c[1], c[2], a]);
}
}
// Scan lines with the module's compression and sample type (`exr_out`).
// Scan lines with the module's compression and sample type (`exr_out`), naming the primaries and
// white the values are in (the output space's), for Nuke, AE and Blender.
let om = cx.output;
exr_out::write_rgba(file, &data, (w as usize, h as usize), (om.exr_compression, om.exr_bit_depth)).map_err(|e| ExportError::Encode(e.to_string()))
let chroma = Some(pipe.output_linear_space().exr_chromaticities());
exr_out::write_rgba(file, &data, (w as usize, h as usize), (om.exr_compression, om.exr_bit_depth), chroma)
.map_err(|e| ExportError::Encode(e.to_string()))
}
OutputFormat::JpegSequence => {
let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h);
@@ -417,3 +417,58 @@ fn exr_decodes_with_the_output_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}");
}
}
+7 -1
View File
@@ -128,7 +128,13 @@ impl Pipe {
/// Gamma's when unmanaged (sRGB at 2.2, a pure 2.4 power curve at 2.4). The identity for a
/// linear output (ACES).
pub fn output_linear(&self) -> Conversion {
Conversion::linearize(if self.space.is_some() { self.out } else { self.curve() })
Conversion::linearize(self.output_linear_space())
}
/// The space whose primaries and white the frame of [`Pipe::output_linear`] is in: the output
/// space, or the Working Gamma's (sRGB or Rec. 709, the same primaries) when unmanaged.
pub fn output_linear_space(&self) -> ColorSpace {
if self.space.is_some() { self.out } else { self.curve() }
}
/// Output with HDR compand / tone mapping (standard-dynamic-range outputs only): working
+3 -1
View File
@@ -414,7 +414,9 @@ mapping) is the last top-level step, for standard-dynamic-range displays and out
renders (sequences and ProEXR) skip it (`RenderOpts::skip_hdr_step`) and decode the frame with the
output space's own curve (`Pipe::output_linear`), so they hold linear light in the output space's
primaries; unmanaged projects decode with the Working Gamma's curve (sRGB at 2.2, a pure 2.4 power
curve at 2.4). Unmanaged projects and Rec. 2100 PQ / HLG outputs never get the HDR step.
curve at 2.4). Every OpenEXR file (sequences and ProEXR) names those primaries and the white point in its
`chromaticities` attribute (`ColorSpace::exr_chromaticities`; Rec. 709 / sRGB primaries for the default
and for unmanaged projects, Rec. 2100 PQ and HLG as BT.2020, the ACES spaces with the ACES white). Unmanaged projects and Rec. 2100 PQ / HLG outputs never get the HDR step.
**Frame blending**: footage and precomp layers whose source time falls between source frames
(rate conform, stretch, remap) blend the two neighbouring frames, by cross-fade (Frame Mix) or by
+1 -1
View File
@@ -41,7 +41,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| PNG | R/W | yes | yes (sequence) | good |
| JPEG | R/W | yes | yes (sequence) | good |
| TIFF | R/W | yes | yes (sequence) | good |
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (scan-line sequence, half or 32-bit float, every compression but HTJ2K) | good; HTJ2K compression fails (#482); a multi-layer frame decodes once for its picture and EXtractoR / Cryptomatte from the first frame on, except Media Replacement items, which are learned at their first frame, and files whose picture the `image` crate still decodes (#474); footage alone keeps only its picture's channels, though a single-part file still decompresses every channel's pixels; decoded channels are held as f32 |
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (scan-line sequence, half or 32-bit float, every compression but HTJ2K) | good; written files carry a `chromaticities` attribute for the output space's primaries and white; HTJ2K compression fails (#482); a multi-layer frame decodes once for its picture and EXtractoR / Cryptomatte from the first frame on, except Media Replacement items, which are learned at their first frame, and files whose picture the `image` crate still decodes (#474); footage alone keeps only its picture's channels, though a single-part file still decompresses every channel's pixels; decoded channels are held as f32 |
| Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec |
| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes; Composition and Composition - Retain Layer Sizes, #1536) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS |
| SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't |