mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 02:41:57 -06:00
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:
co-authored by
Claude Opus 5.5
parent
a646bdcc2e
commit
684e05859f
@@ -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
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user