From 406c9a9648d1e4d1eedc7b594d9d65efa9238a55 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 01:57:53 +0100 Subject: [PATCH] EXR export: raw alpha for Alpha-only files, skip_hdr_step name, more tests - Rename RenderOpts::scene_output to skip_hdr_step (Pipe::without_hdr_step): the consumer decodes with Pipe::output_linear or applies the step itself. - Alpha-only EXRs hold the alpha as it is in every output space (it is coverage, not encoded colour; it used to be sRGB-decoded). - Tests: a half-opacity layer over a coloured background (premultiplied and RGB-over-background paths, golden hashes taken before the change), Rec. 2100 PQ and HLG EXR outputs, raw alpha, and a visible skip of the GPU test without an adapter. - docs/architecture.md: the Working Gamma curve for unmanaged projects, and no HDR step for unmanaged projects or PQ / HLG outputs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/frame_export.rs | 15 +-- crates/export/src/lib.rs | 8 +- crates/export/src/pipeline.rs | 2 +- crates/export/tests/it/exr_scene_linear.rs | 148 +++++++++++++++++++-- crates/gpu/src/tests.rs | 14 +- crates/render/src/accel.rs | 7 +- crates/render/src/color.rs | 8 +- crates/render/src/opts.rs | 12 +- crates/render/src/tests/color.rs | 8 +- crates/ui-egui/src/app/viewer_frames.rs | 2 +- crates/ui-egui/src/frames/tests.rs | 6 +- docs/architecture.md | 5 +- 12 files changed, 182 insertions(+), 53 deletions(-) diff --git a/crates/engine/src/cmd/frame_export.rs b/crates/engine/src/cmd/frame_export.rs index 24b6f0c3e..7347b84bb 100644 --- a/crates/engine/src/cmd/frame_export.rs +++ b/crates/engine/src/cmd/frame_export.rs @@ -27,15 +27,14 @@ struct Isolated { image: 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 { +/// `skip_hdr_step`: no HDR step, for a float output (ProEXR; [`RenderOpts::skip_hdr_step`]). +fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64, skip_hdr_step: 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, + skip_hdr_step, ..Default::default() }; let mut r = Renderer::new(project, s.footage.as_ref(), opts); @@ -44,11 +43,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). `scene_output`: as +/// The comp frame and each visible layer rendered alone (top layer first). `skip_hdr_step`: as /// [`render_with`]. -fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64, scene_output: bool) -> Result<(Image, Vec)> { +fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64, skip_hdr_step: bool) -> Result<(Image, Vec)> { let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let full = render_with(s, &s.project, cid, t, scale, scene_output); + let full = render_with(s, &s.project, cid, t, scale, skip_hdr_step); let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.has_video()); let ids: Vec = comp .layers @@ -76,7 +75,7 @@ fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64, scene_output: bool) -> } } let Some((name, blend, opacity)) = info else { continue }; - let image = render_with(s, &p, cid, t, scale, scene_output); + let image = render_with(s, &p, cid, t, scale, skip_hdr_step); out.push(Isolated { name, blend, opacity, image }); } Ok((full, out)) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 2d0d38071..9a9e66d0f 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -302,7 +302,7 @@ fn write_still(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). - // The frame was rendered without the HDR step (`RenderOpts::scene_output`) and is + // 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 lin3 = |c: [f32; 3]| dec.apply(c.map(|v| v.max(0.0))); @@ -311,10 +311,8 @@ fn write_still(cx: &Cx, file: &mut W, img: &I for x in 0..w { let p = if x < img.width && y < img.height { img.data[img.idx(x, y)] } else { [0.0; 4] }; let (c, a) = match channels { - Channels::Alpha => { - let a = p[3].clamp(0.0, 1.0); - (lin3([a; 3]), 1.0) - } + // Alpha is coverage, not encoded colour: written as it is. + Channels::Alpha => ([p[3].clamp(0.0, 1.0); 3], 1.0), Channels::Rgba => { let a = p[3].clamp(0.0, 1.0); let c = if a > 0.0 { lin3([p[0] / a, p[1] / a, p[2] / a]).map(|v| v * a) } else { [0.0; 3] }; diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 3742ae0a2..01e3f3faf 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -252,7 +252,7 @@ impl<'a> Cx<'a> { 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, + skip_hdr_step: 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); diff --git a/crates/export/tests/it/exr_scene_linear.rs b/crates/export/tests/it/exr_scene_linear.rs index 99a419894..b4b8c027c 100644 --- a/crates/export/tests/it/exr_scene_linear.rs +++ b/crates/export/tests/it/exr_scene_linear.rs @@ -43,6 +43,34 @@ fn project(depth: BitDepth, hdr: HdrMode, ws: Option, linearize: boo (p, cid) } +/// A comp builder: bit depth, HDR mode, working space, Linearize Working Space, output space. +type Build = fn(BitDepth, HdrMode, Option, bool, Option) -> (Project, ItemId); + +/// The half-opacity layer of [`half_project`]: over range in red. +const HALF: [f32; 3] = [4.0, 0.6, 0.3]; + +/// A one-frame comp over a coloured background whose only layer, [`HALF`] at 50% opacity, +/// covers the left two thirds: semi-transparent and transparent pixels (the premultiplied and +/// RGB-over-background paths of the writers). +fn half_project(depth: BitDepth, hdr: HdrMode, ws: Option, linearize: bool, out: Option) -> (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.2, 0.3, 0.4]; + let w = W / 3 * 2; + let sid = p.add_item("Half", Label::Red, None, ItemKind::Solid(Solid { color: HALF, width: w, height: H, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "Half", LayerSource::Solid { item: sid }, (w, H), None); + l.props.prop_mut("transform/position").expect("position").value = effectcraft_keyframe::Value::Vec3([w as f64 / 2.0, H as f64 / 2.0, 0.0]); + l.props.prop_mut("transform/opacity").expect("opacity").value = effectcraft_keyframe::Value::Scalar(50.0); + comp.layers.push(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); @@ -80,11 +108,11 @@ const STILLS: [(OutputFormat, Channels, &str); 3] = [ /// 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 { +fn still_hashes(build: Build) -> Vec { 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)); + let (p, cid) = build(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"); @@ -130,9 +158,41 @@ const STILL_GOLDEN: [u64; 27] = [ #[test] fn eight_bit_stills_are_unchanged() { - assert_eq!(still_hashes(), STILL_GOLDEN); + assert_eq!(still_hashes(project), STILL_GOLDEN); + assert_eq!(still_hashes(half_project), HALF_STILL_GOLDEN); } +/// [`still_hashes`] of [`half_project`] before: premultiplied and RGB-over-background stills. +const HALF_STILL_GOLDEN: [u64; 27] = [ + 0x3abd611ee63b7edd, + 0x51e7ec0426773cdb, + 0xbee34abe9f0a3d87, + 0x554c03dba915d290, + 0xe98490fc826a7adb, + 0x0a0e77927bf1a187, + 0x4f1c076634822d50, + 0xe790f474e05b3adb, + 0xe517c3136471a187, + 0x07cd5e73765539f9, + 0x68e51bbd8d442a64, + 0x844a5f23a9cc9a3d, + 0xc302b2b86f4cdd70, + 0xb7247795d204a2c5, + 0x3b18867537c69265, + 0x9a5f7679bb5a2399, + 0x868b729dcc804656, + 0x2e0bc64a3fb562f0, + 0x6dc9e1a1eac1220d, + 0x6e101cd849e78f32, + 0x7b7a2a776f73d70b, + 0x6b3244f808c65d4e, + 0xbfa1b35fada2c77d, + 0x8c3a31415ed47e33, + 0xac787507caf1c896, + 0x4a38297060de19ff, + 0x300d6e4222eaa5b5, +]; + /// The decoded samples of a still EXR frame, row-major RGBA. fn exr_samples(path: &Path) -> Vec { image::open(path).expect("exr").to_rgba32f().into_raw() @@ -143,12 +203,12 @@ fn bits_hash(v: &[f32]) -> u64 { } /// Clip-mode EXR samples with an sRGB output, for each colour setup × bit depth × channels. -fn exr_clip_hashes() -> Vec { +fn exr_clip_hashes(build: Build, channels: &[Channels]) -> Vec { 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 (p, cid) = build(depth, HdrMode::Clip, ws, linearize, None); + for &ch in channels { 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); @@ -200,7 +260,54 @@ const EXR_CLIP_GOLDEN: [u64; 36] = [ #[test] fn exr_clip_with_srgb_output_is_unchanged() { - assert_eq!(exr_clip_hashes(), EXR_CLIP_GOLDEN); + // The bands are opaque everywhere, so the Alpha-only files (alpha 1.0) are unchanged too. + assert_eq!(exr_clip_hashes(project, &[Channels::Rgba, Channels::Rgb, Channels::Alpha]), EXR_CLIP_GOLDEN); + assert_eq!(exr_clip_hashes(half_project, &[Channels::Rgba, Channels::Rgb]), HALF_EXR_CLIP_GOLDEN); +} + +/// [`exr_clip_hashes`] of [`half_project`] before (RGBA premultiplied, RGB over the background). +const HALF_EXR_CLIP_GOLDEN: [u64; 24] = [ + 0xe7f7d517df7baf25, + 0xc9d6dcb2671f9925, + 0x2fe5733c26d16325, + 0x8b267197d2645925, + 0x6fe736332cb80f25, + 0x30d5f66395d92d25, + 0xe7f7d517df7baf25, + 0xc9d6dcb2671f9925, + 0x2fe5733c26d16325, + 0x8b267197d2645925, + 0x6fe736332cb80f25, + 0x30d5f66395d92d25, + 0x8c261c1cd9674b25, + 0xace550c3a8e72925, + 0xb9bd0b7863b78325, + 0xdf8e948c5e8af125, + 0x6fe736332cb80f25, + 0x30d5f66395d92d25, + 0xe4aedfefacc80725, + 0x95ddfccddc885525, + 0x81fa3c39c1d09725, + 0x49cc0014f05a1d25, + 0x4ed1b417cc847725, + 0x2df6df5ce394d925, +]; + +/// An Alpha-only EXR holds the alpha (coverage) as it is in every output space: no transfer +/// curve is decoded from it (it used to be sRGB-decoded, 0.5 → 0.214). +#[test] +fn exr_alpha_only_is_raw_alpha() { + for out in [None, Some(ColorSpace::Rec709), Some(ColorSpace::Rec2100Pq), Some(ColorSpace::AcesCg)] { + for ws in [None, Some(ColorSpace::Srgb)] { + let (p, cid) = half_project(BitDepth::Bpc32, HdrMode::Clip, ws, false, out); + let d = out_dir(&format!("alpha-{ws:?}-{out:?}")); + let v = exr_samples(&render(&p, cid, OutputFormat::ExrSequence, Channels::Alpha, &d, "exr")); + let _ = std::fs::remove_dir_all(&d); + let at = |x: u32| &v[((H / 2 * W + x) * 4) as usize..][..4]; + assert_eq!(at(2), [0.5, 0.5, 0.5, 1.0], "{ws:?} {out:?} covered"); + assert_eq!(at(W - 2), [0.0, 0.0, 0.0, 1.0], "{ws:?} {out:?} transparent"); + } + } } /// Compand and tone mapping leave EXR output alone: the file holds the Clip mode's samples, for @@ -215,17 +322,19 @@ fn exr_ignores_compand_and_tone_mapping() { ]; for (ws, linearize, out) in setups { for depth in DEPTHS { - for ch in [Channels::Rgba, Channels::Rgb] { + for (ch, (name, build)) in + [Channels::Rgba, Channels::Rgb].into_iter().flat_map(|c| [(c, ("bands", project as Build)), (c, ("half", half_project as Build))]) + { 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 (p, cid) = build(depth, mode, ws, linearize, out); + let d = out_dir(&format!("modes-{name}-{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:?}"); + assert!(samples(mode) == clip, "{name} {ws:?} {linearize} {out:?} {depth:?} {ch:?} {mode:?}"); } } } @@ -275,6 +384,23 @@ fn exr_holds_scene_linear_values_in_output_primaries() { assert_eq!(exr_centre(&p, cid, "lin-exact")[..3], band, "4.0 is exactly 4.0"); } +/// Rec. 2100 PQ and HLG outputs (no HDR step there in any mode) are decoded with their own +/// curves: linear light in Rec. 2020 primaries, 1.0 = reference white (203 cd/m²). +#[test] +fn exr_decodes_pq_and_hlg_outputs() { + let [_, _, band] = BANDS; + let want = Conversion::new(ColorSpace::AcesCg, true, ColorSpace::Rec2020, true).expect("matrix").apply(band).map(|v| v.max(0.0)); + for out in [ColorSpace::Rec2100Pq, ColorSpace::Rec2100Hlg] { + for mode in MODES { + let (p, cid) = project(BitDepth::Bpc32, mode, Some(ColorSpace::AcesCg), true, Some(out)); + let got = exr_centre(&p, cid, &format!("hdr-out-{out:?}-{mode:?}")); + for i in 0..3 { + assert!((got[i] - want[i]).abs() <= 1e-3 * want[i].max(1.0), "{out:?} {mode:?} channel {i}: got {got:?}, want {want:?}"); + } + } + } +} + /// 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] diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 4c09faa29..0d27da63d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -1316,11 +1316,16 @@ fn owned_devices_favour_memory_usage() { 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 +/// `RenderOpts::skip_hdr_step` (float outputs) 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() { +fn skipping_the_hdr_step_on_the_gpu_matches_clip() { use effectcraft_project::HdrMode; + let Some(g) = gpu() else { + // Visible skip (EFFECTCRAFT_REQUIRE_GPU=1 makes a missing adapter fail instead). + eprintln!("skipping_the_hdr_step_on_the_gpu_matches_clip: skipped: no GPU adapter"); + return; + }; 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); @@ -1329,9 +1334,8 @@ fn scene_output_skips_the_hdr_step_on_the_gpu() { 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 scene = RenderOpts { skip_hdr_step: true, ..opts() }; + check(&format!("no HDR step {mode:?}"), compare_at(&s, scene, Tick::ZERO), 0.0); 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"); diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index 613853e69..52cd2be4a 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -174,10 +174,11 @@ 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`]). + /// The colour pipeline of the top-level output steps: the project's, with no HDR step under + /// [`crate::RenderOpts::skip_hdr_step`] (the consumer decodes with + /// [`color::Pipe::output_linear`] or applies the step itself). pub fn output_pipe(&self) -> color::Pipe { - if self.opts.scene_output { self.pipe.scene_output() } else { self.pipe } + if self.opts.skip_hdr_step { self.pipe.without_hdr_step() } else { self.pipe } } /// Nesting depth (0 = the top-level comp). diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index 54d6d82e0..16760c95a 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -117,13 +117,13 @@ 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 { + /// The pipe without the HDR step (Clip), as [`crate::RenderOpts::skip_hdr_step`] renders + /// with: [`Pipe::output`] converts to the output space as it is. + pub fn without_hdr_step(self) -> Pipe { Pipe { hdr: HdrMode::Clip, ..self } } - /// The top-level frame of a [`Pipe::scene_output`] render (output-encoded, straight colour) + /// The top-level frame of a [`Pipe::without_hdr_step`] 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). diff --git a/crates/render/src/opts.rs b/crates/render/src/opts.rs index ae8d3435f..352f85983 100644 --- a/crates/render/src/opts.rs +++ b/crates/render/src/opts.rs @@ -28,11 +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, + /// No HDR step: the top-level comp leaves the working space for the output space without + /// Project Settings ▸ HDR (compand / tone mapping), so over-range values stay. The consumer + /// decodes the frame with [`crate::color::Pipe::output_linear`] (float outputs: OpenEXR) + /// or applies the step itself. Part of the viewer's frame keys (`opts_hash`). + pub skip_hdr_step: bool, } impl RenderOpts { @@ -54,7 +54,7 @@ impl Default for RenderOpts { backend: Backend::Cpu, nested_switches: true, draft_shadows: true, - scene_output: false, + skip_hdr_step: false, proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings, } } diff --git a/crates/render/src/tests/color.rs b/crates/render/src/tests/color.rs index de3ad19c6..4e5cff574 100644 --- a/crates/render/src/tests/color.rs +++ b/crates/render/src/tests/color.rs @@ -340,18 +340,18 @@ fn float_footage_is_encoded_once_with_the_working_gamma() { } } -/// A float output (`RenderOpts::scene_output`) skips Project Settings ▸ HDR: its top-level frame +/// `RenderOpts::skip_hdr_step` (float outputs) 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() { +fn skip_hdr_step_frames_are_the_clip_frames() { 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 frame = |p: &Project, skip_hdr_step: bool| { + Renderer::new(p, &crate::NoFootage, RenderOpts { skip_hdr_step, ..Default::default() }).comp_frame(cid, Tick::ZERO) }; let clip = frame(&p, false); assert_eq!(clip.data, frame(&p, true).data, "{depth:?} Clip"); diff --git a/crates/ui-egui/src/app/viewer_frames.rs b/crates/ui-egui/src/app/viewer_frames.rs index 241951c54..7277a7cb5 100644 --- a/crates/ui-egui/src/app/viewer_frames.rs +++ b/crates/ui-egui/src/app/viewer_frames.rs @@ -164,7 +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, + skip_hdr_step: false, proxy: Default::default(), } } diff --git a/crates/ui-egui/src/frames/tests.rs b/crates/ui-egui/src/frames/tests.rs index 77584e2df..3716db953 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -6,12 +6,12 @@ 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 +/// Frames without the HDR step (`RenderOpts::skip_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() { +fn frames_without_the_hdr_step_have_keys_of_their_own() { let opts = RenderOpts::default(); - assert_ne!(opts_hash(&opts), opts_hash(&RenderOpts { scene_output: true, ..opts })); + assert_ne!(opts_hash(&opts), opts_hash(&RenderOpts { skip_hdr_step: true, ..opts })); } struct ProbeDisk { diff --git a/docs/architecture.md b/docs/architecture.md index 226f93a14..48f3ad442 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -409,9 +409,10 @@ Interpret Footage, else sRGB) and the top-level comp is converted to the sRGB di Working Space runs everything in linear light; Blend Colors Using 1.0 Gamma linearises only for 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 +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. +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. **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