diff --git a/crates/engine/src/cmd/frame_export.rs b/crates/engine/src/cmd/frame_export.rs index d8b49159c..7347b84bb 100644 --- a/crates/engine/src/cmd/frame_export.rs +++ b/crates/engine/src/cmd/frame_export.rs @@ -27,12 +27,14 @@ struct Isolated { image: Image, } -fn render_with(s: &Session, project: &Project, comp: ItemId, t: Tick, scale: f64) -> 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, + skip_hdr_step, ..Default::default() }; let mut r = Renderer::new(project, s.footage.as_ref(), opts); @@ -41,10 +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). -fn isolate(s: &Session, cid: ItemId, t: Tick, scale: f64) -> Result<(Image, Vec)> { +/// 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, 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); + 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 @@ -72,7 +75,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, skip_hdr_step); out.push(Isolated { name, blend, opacity, image }); } Ok((full, out)) @@ -111,7 +114,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, 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 +139,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 { @@ -158,15 +157,19 @@ fn exr_name(name: &str, used: &mut Vec) -> 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, Vec, 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; 4] { let mut out: [Vec; 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); } diff --git a/crates/engine/src/tests/frame_export.rs b/crates/engine/src/tests/frame_export.rs index a8dc938fb..8b69ac25d 100644 --- a/crates/engine/src/tests/frame_export.rs +++ b/crates/engine/src/tests/frame_export.rs @@ -230,3 +230,131 @@ 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)> { + 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)> = 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 decodes with the output space's own curve. A Rec. 709 output has the sRGB working +/// space's primaries but another curve, so the composite and the isolated layer must hold the +/// solid's light (its sRGB working value decoded), not the sRGB decode of its Rec. 709 encoding. +#[test] +fn proexr_decodes_a_non_srgb_output_curve() { + let dir = out_dir("frame-exr-rec709"); + 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": 32, "workingSpace": "srgb", "linearize": true, "outputSpace": "rec709"})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Warm", "color": [0.8, 0.5, 0.2]})).unwrap(); + let path = dir.join("rec709.exr").to_string_lossy().to_string(); + s.execute("comp.saveFrameAsExr", json!({"path": path})).unwrap(); + let got = proexr_samples(&path); + let at = |n: &str| got.iter().find(|c| c.0 == n).unwrap_or_else(|| panic!("no {n}")).1[0]; + let lin = effectcraft_color::srgb_to_linear; + for (n, want) in [("R", lin(0.8)), ("G", lin(0.5)), ("B", lin(0.2)), ("Warm.R", lin(0.8)), ("Warm.B", lin(0.2))] { + assert!((at(n) - want).abs() <= 1e-3 * want, "{n}: {} vs {want}", at(n)); + } +} + +/// 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 = + proexr_samples(&path).iter().flat_map(|(n, v)| n.bytes().chain(v.iter().flat_map(|x| x.to_bits().to_le_bytes())).collect::>()).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::>()); + 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)); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 0a8f6b4ec..b93e46d2c 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -316,25 +316,25 @@ 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). - 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::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))); let mut data = Vec::with_capacity((w * h * 4) as usize); for y in 0..h { 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); - ([lin(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 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]); diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index b0d1d96c9..e0c5cfc87 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -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. + 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 new file mode 100644 index 000000000..b4b8c027c --- /dev/null +++ b/crates/export/tests/it/exr_scene_linear.rs @@ -0,0 +1,419 @@ +//! 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, 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.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) +} + +/// 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); + 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(build: Build) -> Vec { + let mut out = vec![]; + for depth in DEPTHS { + for mode in MODES { + 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"); + 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(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() +} + +fn bits_hash(v: &[f32]) -> u64 { + fnv(&v.iter().flat_map(|x| x.to_bits().to_le_bytes()).collect::>()) +} + +/// Clip-mode EXR samples with an sRGB output, for each colour setup × bit depth × channels. +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) = 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); + } + } + } + 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() { + // 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 +/// 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, (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) = 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, "{name} {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"); +} + +/// 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] +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"); +} diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs index f7bfa7a1c..07ee96c05 100644 --- a/crates/export/tests/it/main.rs +++ b/crates/export/tests/it/main.rs @@ -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"))] diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 35694e8a8..b88bac0be 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -1460,6 +1460,38 @@ fn owned_devices_favour_memory_usage() { assert_eq!(d.required_limits.max_texture_dimension_2d, wgpu::Limits::default().max_texture_dimension_2d); } +/// `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 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); + 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 { 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"); + 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"); + } +} + /// A GPU context of its own with a small pool budget, so other tests (sharing [`gpu`]) neither /// see the budget nor add to what the pool keeps. fn private_gpu(budget: usize) -> Option { diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index c7212dc84..f688a6375 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -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); diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index fe5a8f3fe..f545a62ee 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -197,6 +197,13 @@ impl<'a> Renderer<'a> { self.pipe } + /// 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.skip_hdr_step { self.pipe.without_hdr_step() } else { self.pipe } + } + /// Nesting depth (0 = the top-level comp). pub fn depth(&self) -> usize { self.depth diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index d27526553..16760c95a 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -117,6 +117,20 @@ impl Pipe { Conversion::new(ws, self.linear, self.out, false) } + /// 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::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). + 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, HdrMode, Conversion)> { diff --git a/crates/render/src/opts.rs b/crates/render/src/opts.rs index c6175bf39..352f85983 100644 --- a/crates/render/src/opts.rs +++ b/crates/render/src/opts.rs @@ -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, + /// 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 { @@ -49,6 +54,7 @@ impl Default for RenderOpts { backend: Backend::Cpu, nested_switches: true, draft_shadows: true, + skip_hdr_step: false, proxy: effectcraft_project::render_queue::ProxyUse::CurrentSettings, } } diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index ddf671dc4..e84348fdf 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -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); diff --git a/crates/render/src/tests/color.rs b/crates/render/src/tests/color.rs index 29e4d62c2..4e5cff574 100644 --- a/crates/render/src/tests/color.rs +++ b/crates/render/src/tests/color.rs @@ -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); } } + +/// `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 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, 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"); + 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, out: Option, 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:?}"); + } +} diff --git a/crates/ui-egui/src/app/viewer_frames.rs b/crates/ui-egui/src/app/viewer_frames.rs index 98a78ff75..e58655817 100644 --- a/crates/ui-egui/src/app/viewer_frames.rs +++ b/crates/ui-egui/src/app/viewer_frames.rs @@ -173,6 +173,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, + 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 4099aa4c6..be96ecf30 100644 --- a/crates/ui-egui/src/frames/tests.rs +++ b/crates/ui-egui/src/frames/tests.rs @@ -17,6 +17,14 @@ fn converting_a_frame_with_nan_alpha_keeps_the_viewer_alive() { assert_eq!(to_color_image(&image).pixels, [egui::Color32::TRANSPARENT]); } +/// 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 frames_without_the_hdr_step_have_keys_of_their_own() { + let opts = RenderOpts::default(); + assert_ne!(opts_hash(&opts), opts_hash(&RenderOpts { skip_hdr_step: true, ..opts })); +} + struct ProbeDisk { keys: Mutex>, probes: std::sync::atomic::AtomicUsize, diff --git a/docs/architecture.md b/docs/architecture.md index 4400aeb01..260d219a7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -409,7 +409,12 @@ 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::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. **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