diff --git a/crates/color/src/space.rs b/crates/color/src/space.rs index b570975aa..324627934 100644 --- a/crates/color/src/space.rs +++ b/crates/color/src/space.rs @@ -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 { diff --git a/crates/engine/src/cmd/frame_export.rs b/crates/engine/src/cmd/frame_export.rs index 7347b84bb..d6f491fb0 100644 --- a/crates/engine/src/cmd/frame_export.rs +++ b/crates/engine/src/cmd/frame_export.rs @@ -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; 4] { let mut out: [Vec; 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)) } diff --git a/crates/engine/src/tests/frame_export.rs b/crates/engine/src/tests/frame_export.rs index 8b69ac25d..e91e5716d 100644 --- a/crates/engine/src/tests/frame_export.rs +++ b/crates/engine/src/tests/frame_export.rs @@ -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])); +} diff --git a/crates/export/src/exr_out.rs b/crates/export/src/exr_out.rs index 92afadcc8..43467629a 100644 --- a/crates/export/src/exr_out.rs +++ b/crates/export/src/exr_out.rs @@ -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(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( + 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| -> [f32; 4] { let o = p.y().saturating_mul(row).saturating_add(p.x().saturating_mul(4)); @@ -59,7 +73,7 @@ pub fn write_rgba(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(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(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 { 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()); + } + } } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index b3919b5c1..9827ce754 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -319,7 +319,8 @@ fn write_still(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(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(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); diff --git a/crates/export/tests/it/exr_scene_linear.rs b/crates/export/tests/it/exr_scene_linear.rs index b4b8c027c..40dfcdbc9 100644 --- a/crates/export/tests/it/exr_scene_linear.rs +++ b/crates/export/tests/it/exr_scene_linear.rs @@ -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}"); + } +} diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index 16760c95a..c423665d2 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index c5e115bb6..a5937bc3d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f55576d0d..34485fd10 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 |