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:
vangel76
2026-11-03 01:47:24 +01:00
co-authored by Claude Opus 5.5
parent a646bdcc2e
commit 684e05859f
16 changed files with 548 additions and 23 deletions
+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:?}");
}
}