EXR export: no compand / tone mapping, and the output space's own curve

OpenEXR renders (Render Queue sequences and Save Frame As ProEXR) received
the frame after Project Settings > HDR, so Compand and Tone Map (the default
of every OCIO project) compressed highlights and changed values below 1.0
before the writer decoded them, always with the sRGB curve.

- RenderOpts::scene_output: the top-level comp converts to the output space
  without the HDR step (CPU and GPU walks, via Renderer::output_pipe).
- Pipe::output_linear: the writers decode with the output space's curve
  (the Working Gamma's when unmanaged) instead of a fixed sRGB curve.
- Render Queue EXR jobs and ProEXR set the flag; PSD layers, 8-bit stills,
  movies and the viewer keep the HDR step. The flag is part of the viewer
  frame and disk cache keys (opts_hash).

Golden hashes, taken before the change, pin PNG/TIFF/Targa bytes for
8/16/32 bpc x Clip/Compand/ToneMap, Clip-mode EXR samples with an sRGB
output, and the layered PSD bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-11-03 01:47:24 +01:00
1 parent a646bdcc2e
commit 684e05859f
16 files changed
+548 -23

No files matched your search

+16 -12
View File
@@ -27,12 +27,15 @@ struct Isolated {
image: Image,
}
fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
/// `scene_output`: a float output (ProEXR), rendered without the HDR step
/// ([`RenderOpts::scene_output`]).
fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64, scene_output: bool) -> Image {
let opts = RenderOpts {
scale,
backend: Backend::Auto,
nested_switches: s.prefs.general.switches_affect_nested_comps,
draft_shadows: s.prefs.three_d.realtime_shadows,
scene_output,
..Default::default()
};
let mut r = Renderer::new(project, s.footage.as_ref(), opts);
@@ -41,10 +44,11 @@ fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64
r.comp_frame(comp, t)
}
/// The comp frame and each visible layer rendered alone (top layer first).
fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec<Isolated>)> {
/// The comp frame and each visible layer rendered alone (top layer first). `scene_output`: as
/// [`render_with`].
fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64, scene_output: bool) -> Result<(Image, Vec<Isolated>)> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let full = render_with(s, &s.project, cid, t, scale);
let full = render_with(s, &s.project, cid, t, scale, scene_output);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.has_video());
let ids: Vec<LayerId> = comp
.layers
@@ -72,7 +76,7 @@ fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec<
}
}
let Some((name, blend, opacity)) = info else { continue };
let image = render_with(s, &p, cid, t, scale);
let image = render_with(s, &p, cid, t, scale, scene_output);
out.push(Isolated { name, blend, opacity, image });
}
Ok((full, out))
@@ -111,7 +115,7 @@ fn alpha_bounds(img: &Image) -> Option<(u32, u32, u32, u32)> {
pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, usize, u32, u32)> {
use effectcraft_psd::Rect;
use effectcraft_psd::write::{WDoc, WLayer, write};
let (full, layers) = isolate(s, cid, t, scale)?;
let (full, layers) = isolate(s, cid, t, scale, false)?;
let mut doc = WDoc::new(full.width, full.height);
let n = layers.len();
// File order is bottom first.
@@ -136,10 +140,6 @@ pub fn layered_psd(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec
Ok((write(&doc), n, full.width, full.height))
}
fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
/// A unique EXR layer name (no dots: they separate the layer from the channel). Printable ASCII only:
/// the `exr` crate panics on characters it cannot store in a channel name (#1702).
fn exr_name(name: &str, used: &mut Vec<String>) -> String {
@@ -158,15 +158,19 @@ fn exr_name(name: &str, used: &mut Vec<String>) -> String {
/// The multi-layer EXR bytes of a frame and the channel names.
pub fn layered_exr(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Vec<u8>, Vec<String>, u32, u32)> {
use exr::prelude::*;
let (full, layers) = isolate(s, cid, t, scale)?;
// 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 (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();
for p in &img.data {
let sp = straight(*p);
let a = sp[3];
let lin = dec.apply([sp[0].max(0.0), sp[1].max(0.0), sp[2].max(0.0)]);
for c in 0..3 {
out[c].push(srgb_to_linear(sp[c].max(0.0)) * a);
out[c].push(lin[c] * a);
}
out[3].push(a);
}
+108
View File
@@ -230,3 +230,111 @@ fn save_frame_as_queued_respects_frame_default_template() {
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap();
assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok());
}
fn fnv(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ *b as u64).wrapping_mul(0x0100_0000_01b3))
}
/// A 32×32 comp in a managed project (sRGB working space, linearized): a full-frame over-range
/// solid under a 16×16 in-range Multiply solid at 50%.
fn hdr_frame(bit_depth: u64, hdr: &str) -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Frame", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
s.execute("file.projectSettings", json!({"bitDepth": bit_depth, "workingSpace": "srgb", "linearize": true, "hdr": hdr})).unwrap();
s.execute("layer.newSolid", json!({"name": "Bright", "color": [4.0, 2.0, 0.5]})).unwrap();
let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": [0.85, 0.9, 0.2], "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setBlendMode", json!({"layers": [box_], "mode": "Multiply"})).unwrap();
s.execute("prop.set", json!({"layer": box_, "path": "transform/opacity", "value": 50})).unwrap();
s
}
/// Photoshop Layers keeps the HDR step (it is a display-referred output): its bytes for each bit
/// depth × HDR mode.
#[test]
fn photoshop_layers_bytes_are_unchanged() {
let dir = out_dir("frame-psd-golden");
let mut got = vec![];
for depth in [8, 16, 32] {
for hdr in ["clip", "compand", "toneMap"] {
let mut s = hdr_frame(depth, hdr);
let path = dir.join(format!("{depth}-{hdr}.psd")).to_string_lossy().to_string();
s.execute("comp.saveFrameAsPsd", json!({"path": path})).unwrap();
got.push(fnv(&std::fs::read(&path).unwrap()));
}
}
// The hashes before ProEXR skipped the HDR step.
let want: [u64; 9] = [
0x3b921d4d9fc3119f,
0x1886d026edf36a7f,
0x6b564aa7fb50139f,
0xabd585992079fc9f,
0x88ca38726eaa557f,
0xa3636606d7a4927f,
0x7385ed211e9815ff,
0x7385ed211e9815ff,
0xfb59fa3f3bdfac3f,
];
assert_eq!(got, want);
}
/// Every sample of a ProEXR file, channels in name order.
fn proexr_samples(path: &str) -> Vec<(String, Vec<f32>)> {
use exr::prelude::*;
let img = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_file(path).unwrap();
let mut out: Vec<(String, Vec<f32>)> = img.layer_data[0]
.channel_data
.list
.iter()
.map(|c| match &c.sample_data {
FlatSamples::F32(v) => (c.name.to_string(), v.clone()),
o => panic!("{o:?}"),
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
/// ProEXR samples in Clip mode (sRGB output) for each bit depth.
#[test]
fn proexr_clip_samples_are_unchanged() {
let dir = out_dir("frame-exr-clip-golden");
let mut got = vec![];
for depth in [8, 16, 32] {
let mut s = hdr_frame(depth, "clip");
let path = dir.join(format!("{depth}.exr")).to_string_lossy().to_string();
s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
let bytes: Vec<u8> =
proexr_samples(&path).iter().flat_map(|(n, v)| n.bytes().chain(v.iter().flat_map(|x| x.to_bits().to_le_bytes())).collect::<Vec<u8>>()).collect();
got.push(fnv(&bytes));
}
// The hashes before ProEXR skipped the HDR step.
let want: [u64; 3] = [0x4b9ecc87e6db7d03, 0xe06a35d302e71503, 0x694ca552f3efc903];
assert_eq!(got, want);
}
/// ProEXR ignores compand and tone mapping: the composite and every isolated layer hold the Clip
/// mode's samples, and over-range values stay.
#[test]
fn proexr_layers_are_not_tone_mapped() {
let dir = out_dir("frame-exr-hdr");
let samples = |depth: u64, hdr: &str| {
let mut s = hdr_frame(depth, hdr);
let path = dir.join(format!("{depth}-{hdr}.exr")).to_string_lossy().to_string();
s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap();
proexr_samples(&path)
};
for depth in [8, 16, 32] {
let clip = samples(depth, "clip");
assert!(clip.iter().any(|(n, _)| n == "Bright.R") && clip.iter().any(|(n, _)| n == "Box.R"), "{:?}", clip.iter().map(|c| &c.0).collect::<Vec<_>>());
for hdr in ["compand", "toneMap"] {
assert!(samples(depth, hdr) == clip, "{depth} bpc {hdr}");
}
}
let tone = samples(32, "toneMap");
let at = |n: &str| tone.iter().find(|c| c.0 == n).unwrap().1[0];
// The solid's colour is sRGB-encoded in the working space (4.0 is about 25.3 in linear light).
let lin = effectcraft_color::srgb_to_linear;
for (n, want) in [("Bright.R", lin(4.0)), ("Bright.G", lin(2.0)), ("Bright.B", lin(0.5)), ("R", lin(4.0))] {
assert!((at(n) - want).abs() <= 1e-4 * want, "{n}: {} vs {want}", at(n));
}
}
+8 -6
View File
@@ -302,7 +302,10 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
OutputFormat::ExrSequence => {
// Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background.
// Colour is floored at 0 but not capped at 1: a 32 bpc frame's over-range values survive (#339).
let lin = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
// The frame was rendered without the HDR step (`RenderOpts::scene_output`) 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 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 {
for x in 0..w {
@@ -310,17 +313,16 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
let (c, a) = match channels {
Channels::Alpha => {
let a = p[3].clamp(0.0, 1.0);
([lin(a); 3], 1.0)
(lin3([a; 3]), 1.0)
}
Channels::Rgba => {
let a = p[3].clamp(0.0, 1.0);
let s = |c: f32| if a > 0.0 { lin((c / a).max(0.0)) * a } else { 0.0 };
([s(p[0]), s(p[1]), s(p[2])], a)
let c = if a > 0.0 { lin3([p[0] / a, p[1] / a, p[2] / a]).map(|v| v * a) } else { [0.0; 3] };
(c, a)
}
Channels::Rgb => {
let k = 1.0 - p[3].clamp(0.0, 1.0);
let c = |i: usize| lin((p[i] + comp.background[i] * k).max(0.0));
([c(0), c(1), c(2)], 1.0)
(lin3([0, 1, 2].map(|i| p[i] + comp.background[i] * k)), 1.0)
}
};
data.extend_from_slice(&[c[0], c[1], c[2], a]);
+2
View File
@@ -251,6 +251,8 @@ impl<'a> Cx<'a> {
roi: None,
nested_switches: self.job.nested_switches,
draft_shadows: true,
// OpenEXR keeps over-range values: no HDR compand / tone mapping on its frames.
scene_output: self.job.output.format == effectcraft_project::render_queue::OutputFormat::ExrSequence,
proxy: s.proxy_use,
};
let mut r = Renderer::new(&self.project, self.job.footage, opts);
+293
View File
@@ -0,0 +1,293 @@
//! 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)
}
/// 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() -> Vec<u64> {
let mut out = vec![];
for depth in DEPTHS {
for mode in MODES {
let (p, cid) = project(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(), STILL_GOLDEN);
}
/// 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>>())
}
/// Clip-mode EXR samples with an sRGB output, for each colour setup × bit depth × channels.
fn exr_clip_hashes() -> 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) = project(depth, HdrMode::Clip, ws, linearize, None);
for ch in [Channels::Rgba, Channels::Rgb, Channels::Alpha] {
let d = out_dir(&format!("clip-{ws:?}-{linearize}-{depth:?}-{ch:?}"));
out.push(bits_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] = [
0x9f54fea3d3abcb25,
0x9f54fea3d3abcb25,
0x7d40ad9df8600325,
0x379f5ef9e35ce325,
0x379f5ef9e35ce325,
0x7d40ad9df8600325,
0xb60de6a90263b525,
0xb60de6a90263b525,
0x7d40ad9df8600325,
0x9f54fea3d3abcb25,
0x9f54fea3d3abcb25,
0x7d40ad9df8600325,
0x379f5ef9e35ce325,
0x379f5ef9e35ce325,
0x7d40ad9df8600325,
0xb60de6a90263b525,
0xb60de6a90263b525,
0x7d40ad9df8600325,
0x27858400549ac325,
0x27858400549ac325,
0x7d40ad9df8600325,
0x379f5ef9e35ce325,
0x379f5ef9e35ce325,
0x7d40ad9df8600325,
0xb60de6a90263b525,
0xb60de6a90263b525,
0x7d40ad9df8600325,
0x31d03697119d9325,
0x31d03697119d9325,
0x7d40ad9df8600325,
0xff61dc513c834b25,
0xff61dc513c834b25,
0x7d40ad9df8600325,
0x0c57d24a44f45925,
0x0c57d24a44f45925,
0x7d40ad9df8600325,
];
#[test]
fn exr_clip_with_srgb_output_is_unchanged() {
assert_eq!(exr_clip_hashes(), EXR_CLIP_GOLDEN);
}
/// 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 in [Channels::Rgba, Channels::Rgb] {
let samples = |mode: HdrMode| {
let (p, cid) = project(depth, mode, ws, linearize, out);
let d = out_dir(&format!("modes-{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, "{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");
}
/// 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");
}
+1
View File
@@ -2,6 +2,7 @@
mod audio_sample_positions;
mod banding;
mod exr_scene_linear;
mod h264_gpu_yuv;
mod hevc_av1_roundtrip;
#[cfg(not(target_arch = "wasm32"))]
+28
View File
@@ -1315,3 +1315,31 @@ fn owned_devices_favour_memory_usage() {
assert_eq!(d.label, Some("effectcraft gpu"));
assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d);
}
/// A float output (`RenderOpts::scene_output`) skips Project Settings ▸ HDR on the GPU as on the
/// CPU: both compositors agree, and the frame is the Clip frame with its over-range values.
#[test]
fn scene_output_skips_the_hdr_step_on_the_gpu() {
use effectcraft_project::HdrMode;
for mode in [HdrMode::Compand, HdrMode::ToneMap] {
let mut s = blend_scene(BitDepth::Bpc32, BlendMode::Normal);
let mut bright = s.solid([3.0, 1.5, 0.5], 30, 20);
bright.blend_mode = BlendMode::Add;
s.push(bright);
s.p.settings.working_space = Some(ColorSpace::Srgb);
s.p.settings.linearize = true;
s.p.settings.hdr = mode;
let scene = RenderOpts { scene_output: true, ..opts() };
check(&format!("scene output {mode:?}"), compare_at(&s, scene, Tick::ZERO), 0.0);
let Some(g) = gpu() else { continue };
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..scene });
r.accel = Some(g);
let img = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame");
let mut clip = s.p.clone();
clip.settings.hdr = HdrMode::Clip;
let want = Renderer::new(&clip, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO);
let d = diff(&want, &img, tolerance(BitDepth::Bpc32));
assert_eq!(d.over, 0, "{mode:?}: max {} at {:?}", d.max, d.worst);
assert!(img.data.iter().any(|p| p[3] > 0.0 && p[0] / p[3] > 1.0), "{mode:?}: over-range values survive");
}
}
+3 -2
View File
@@ -45,8 +45,9 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick
if let Some(l) = pipe.levels {
canvas = ops::quantize(e, &canvas, l);
}
let out = r.output_pipe();
if r.depth() == 0
&& let Some(c) = pipe.output()
&& let Some(c) = out.output()
{
canvas = ops::convert(e, &canvas, &c);
if let Some(l) = pipe.levels {
@@ -54,7 +55,7 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick
}
}
if r.depth() == 0
&& let Some((lin, mode, enc)) = pipe.output_hdr()
&& let Some((lin, mode, enc)) = out.output_hdr()
{
on_cpu(e, &mut canvas, |img| {
effectcraft_render::color::output_hdr(img, lin, mode, enc);
+6
View File
@@ -174,6 +174,12 @@ impl<'a> Renderer<'a> {
self.pipe
}
/// The colour pipeline of the top-level output steps: the project's, without the HDR step
/// for a float output ([`crate::RenderOpts::scene_output`]).
pub fn output_pipe(&self) -> color::Pipe {
if self.opts.scene_output { self.pipe.scene_output() } else { self.pipe }
}
/// Nesting depth (0 = the top-level comp).
pub fn depth(&self) -> usize {
self.depth
+14
View File
@@ -117,6 +117,20 @@ impl Pipe {
Conversion::new(ws, self.linear, self.out, false)
}
/// The pipe of a float output ([`crate::RenderOpts::scene_output`]): the same, without
/// the HDR step (Clip), so [`Pipe::output`] converts to the output space as it is.
pub fn scene_output(self) -> Pipe {
Pipe { hdr: HdrMode::Clip, ..self }
}
/// The top-level frame of a [`Pipe::scene_output`] render (output-encoded, straight colour)
/// → linear light in the output space's primaries: the output space's curve, or the Working
/// 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() })
}
/// Output with HDR compand / tone mapping (standard-dynamic-range outputs only): working
/// space → linear output primaries, the HDR curve, then the output's encoding.
pub fn output_hdr(&self) -> Option<(Option<Conversion>, HdrMode, Conversion)> {
+6
View File
@@ -28,6 +28,11 @@ pub struct RenderOpts {
pub nested_switches: bool,
/// Realtime Shadows in Draft (Settings ▸ 3D): draft renders (`draft`) still cast shadows.
pub draft_shadows: bool,
/// A float output (OpenEXR): the top-level comp leaves the working space for the output
/// space without Project Settings ▸ HDR (compand / tone mapping are for standard-dynamic-range
/// displays and outputs), so over-range values reach the file as they are. The writer
/// decodes the frame with [`crate::color::Pipe::output_linear`].
pub scene_output: bool,
}
impl RenderOpts {
@@ -49,6 +54,7 @@ impl Default for RenderOpts {
backend: Backend::Cpu,
nested_switches: true,
draft_shadows: true,
scene_output: false,
proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings,
}
}
+3 -2
View File
@@ -473,14 +473,15 @@ impl<'a> Renderer<'a> {
self.pipe.quantize(&mut canvas);
}
// The top-level comp leaves the working space for the (sRGB) display / output.
let out = self.output_pipe();
if self.depth == 0
&& let Some(c) = self.pipe.output()
&& let Some(c) = out.output()
{
color::convert(&mut canvas, &c);
self.pipe.quantize(&mut canvas);
}
if self.depth == 0
&& let Some((lin, mode, enc)) = self.pipe.output_hdr()
&& let Some((lin, mode, enc)) = out.output_hdr()
{
color::output_hdr(&mut canvas, lin, mode, enc);
self.pipe.quantize(&mut canvas);
+46
View File
@@ -339,3 +339,49 @@ fn float_footage_is_encoded_once_with_the_working_gamma() {
assert_eq!(render(2.4, Some(ColorSpace::Srgb), linear_light), srgb);
}
}
/// A float output (`RenderOpts::scene_output`) skips Project Settings ▸ HDR: its top-level frame
/// is the Clip frame in every mode, while other renders keep compand / tone mapping.
#[test]
fn scene_output_skips_the_hdr_step() {
use effectcraft_project::HdrMode;
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
let (mut p, cid, _) = setup(depth);
p.settings.working_space = Some(ColorSpace::Srgb);
p.settings.linearize = true;
add_solid(&mut p, cid, [4.0, 2.0, 0.5], BlendMode::Normal, 100.0);
let frame = |p: &Project, scene_output: bool| {
Renderer::new(p, &crate::NoFootage, RenderOpts { scene_output, ..Default::default() }).comp_frame(cid, Tick::ZERO)
};
let clip = frame(&p, false);
assert_eq!(clip.data, frame(&p, true).data, "{depth:?} Clip");
for mode in [HdrMode::Compand, HdrMode::ToneMap] {
p.settings.hdr = mode;
assert_eq!(frame(&p, true).data, clip.data, "{depth:?} {mode:?}");
assert_ne!(frame(&p, false).data, clip.data, "{depth:?} {mode:?} keeps the HDR step");
}
}
}
/// `Pipe::output_linear` decodes with the output space's curve (the Working Gamma's when
/// unmanaged) and undoes `Pipe::output` exactly where the primaries match.
#[test]
fn output_linear_decodes_with_the_output_curve() {
use effectcraft_color::Conversion;
use effectcraft_project::ProjectSettings;
let pipe = |ws: Option<ColorSpace>, out: Option<ColorSpace>, gamma: f64| {
crate::color::Pipe::of(&ProjectSettings { working_space: ws, output_space: out, working_gamma: gamma, ..Default::default() })
};
assert_eq!(pipe(None, None, 2.2).output_linear(), Conversion::linearize(ColorSpace::Srgb));
assert_eq!(pipe(None, Some(ColorSpace::DisplayP3), 2.4).output_linear(), Conversion::linearize(ColorSpace::Rec709), "unmanaged: the Working Gamma");
assert_eq!(pipe(Some(ColorSpace::Srgb), None, 2.4).output_linear(), Conversion::linearize(ColorSpace::Srgb));
assert_eq!(pipe(Some(ColorSpace::Srgb), Some(ColorSpace::Rec709), 2.2).output_linear(), Conversion::linearize(ColorSpace::Rec709));
// Linear light in, linear light out: 4.0 stays 4.0 through output + output_linear.
let p = pipe(Some(ColorSpace::Rec709), Some(ColorSpace::Srgb), 2.2);
let p = crate::color::Pipe { linear: true, ..p };
let enc = p.output().unwrap().apply([4.0, 1.0, 0.25]);
let lin = p.output_linear().apply(enc);
for (got, want) in lin.into_iter().zip([4.0, 1.0, 0.25]) {
assert!((got - want).abs() <= 1e-5 * want, "{lin:?}");
}
}
+1
View File
@@ -164,6 +164,7 @@ impl EffectcraftApp {
backend: effectcraft_engine::render::Backend::Auto,
nested_switches: self.session.prefs.general.switches_affect_nested_comps,
draft_shadows: self.session.prefs.three_d.realtime_shadows,
scene_output: false,
proxy: Default::default(),
}
}
+8
View File
@@ -6,6 +6,14 @@ fn converting_a_frame_with_nan_alpha_keeps_the_viewer_alive() {
assert_eq!(to_color_image(&image).pixels, [egui::Color32::TRANSPARENT]);
}
/// A float output's frames (`RenderOpts::scene_output`, no HDR step) never share a RAM or disk
/// cache key with the HDR-mapped frames of the same options.
#[test]
fn scene_output_frames_have_keys_of_their_own() {
let opts = RenderOpts::default();
assert_ne!(opts_hash(&opts), opts_hash(&RenderOpts { scene_output: true, ..opts }));
}
struct ProbeDisk {
keys: Mutex<HashSet<u128>>,
probes: std::sync::atomic::AtomicUsize,
+5 -1
View File
@@ -407,7 +407,11 @@ inputs in 32 bpc. With a working space (sRGB, Rec. 709, Rec. 2020, Display P3; m
from the published primaries), footage is converted from its colour profile (stream metadata or
Interpret Footage, else sRGB) and the top-level comp is converted to the sRGB display. Linearize
Working Space runs everything in linear light; Blend Colors Using 1.0 Gamma linearises only for
blending. Layer cache keys include these settings.
blending. Layer cache keys include these settings. Project Settings ▸ HDR (compand / tone
mapping) is the last top-level step, for standard-dynamic-range displays and outputs; OpenEXR
renders (sequences and ProEXR) skip it (`RenderOpts::scene_output`) 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.
**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