mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-06 18:41:26 +00:00
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:
1 parent
ee7c51740d
commit
ae068ff931
9 files changed
+150
-12
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user