diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index c423665d2..158dde42f 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -153,6 +153,26 @@ impl Pipe { p } + /// A copy of `src` converted by `c` and quantised to the bit depth, in one pass: one read and + /// one write of the frame instead of a copy, a conversion and a quantise pass (a 1080p float + /// frame is 33 MB, and these passes are limited by memory speed). The same pixels as + /// [`convert`] then [`Pipe::quantize`]. + pub fn copy_converted(&self, src: &Image, c: Option<&Conversion>) -> Image { + let levels = self.levels; + let data = src + .data + .par_iter() + .map(|&p| { + let mut p = c.map_or(p, |c| convert_px(p, c)); + if let Some(l) = levels { + quantize_px(&mut p, l, 1.0 / l); + } + p + }) + .collect(); + Image { width: src.width, height: src.height, data } + } + /// Clamp and quantise to the bit depth (no-op in 32 bpc). pub fn quantize(&self, img: &mut Image) { self.quantize_region(img, Region::Full); @@ -265,14 +285,18 @@ fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { /// Convert premultiplied pixels (on straight colour). pub fn convert(img: &mut Image, c: &Conversion) { - img.data.par_iter_mut().for_each(|p| { - let a = p[3]; - if a <= 0.0 { - return; - } - let o = c.apply([p[0] / a, p[1] / a, p[2] / a]); - *p = [o[0] * a, o[1] * a, o[2] * a, a]; - }); + img.data.par_iter_mut().for_each(|p| *p = convert_px(*p, c)); +} + +/// [`convert`] of one pixel. +#[inline] +fn convert_px(p: [f32; 4], c: &Conversion) -> [f32; 4] { + let a = p[3]; + if a <= 0.0 { + return p; + } + let o = c.apply([p[0] / a, p[1] / a, p[2] / a]); + [o[0] * a, o[1] * a, o[2] * a, a] } /// Compand: an exponential knee from 80%, approaching 1.0 (linear light, per channel). @@ -349,6 +373,35 @@ mod tests { } } + /// The fused footage pass gives the pixels of a copy, a conversion and a quantise pass, bit + /// for bit, at every depth, with and without a conversion. + #[test] + fn copy_converted_is_convert_then_quantize() { + let mut img = Image::new(17, 5); + for (i, p) in img.data.iter_mut().enumerate() { + *p = match i % 7 { + 0 => [0.3, -0.2, 0.71, 0.9], + 1 => [-0.0, 0.0, 0.0, 0.0], + 2 => [1.7, 0.4, f32::NAN, 0.5], + 3 => [0.0; 4], + _ => [i as f32 / 90.0, 0.5, 0.25, 1.0], + }; + } + for depth in [effectcraft_project::BitDepth::Bpc8, effectcraft_project::BitDepth::Bpc16, effectcraft_project::BitDepth::Bpc32] { + let pipe = Pipe::of(&ProjectSettings { bit_depth: depth, ..Default::default() }); + for c in [None, Some(Conversion::linearize(ColorSpace::Srgb))] { + let mut want = img.clone(); + if let Some(c) = &c { + convert(&mut want, c); + } + pipe.quantize(&mut want); + let got = pipe.copy_converted(&img, c.as_ref()); + let bits = |i: &Image| i.data.iter().map(|p| p.map(f32::to_bits)).collect::>(); + assert_eq!(bits(&got), bits(&want), "{depth:?} conversion {}", c.is_some()); + } + } + } + /// Both forms `round_half_up` picks between agree on everything quantising feeds it: /// 0..=levels, exact halves included. #[test] diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 6265cb5f7..6979afc85 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -878,6 +878,12 @@ impl<'a> Renderer<'a> { /// Source pixels of a layer in layer space (before masks/effects). pub(crate) fn source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + self.source_with(ctx, layer, false) + } + + /// [`Renderer::source`]; with `quantized`, footage that [`Renderer::prequantized_footage`] + /// counts comes quantised to the project depth. + fn source_with(&self, ctx: &EvalCtx, layer: &Layer, quantized: bool) -> Option { let s = self.opts.scale; match &layer.source { LayerSource::Solid { item } => { @@ -910,7 +916,7 @@ impl<'a> Renderer<'a> { && let Some(nc) = self.project.comp(*item) { let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?; - return Some(self.footage_buf(&img, pf, nc.width, nc.height)); + return Some(self.footage_buf(&img, pf, nc.width, nc.height, false)); } // Essential Properties overrides render the nested comp with this instance's // values. @@ -959,7 +965,7 @@ impl<'a> Renderer<'a> { } } let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?; - Some(self.footage_buf(&img, pf, f.width, f.height)) + Some(self.footage_buf(&img, pf, f.width, f.height, quantized && Self::prequantized_footage(layer))) } // (the font files go to this render's log, which `ctx` may not carry: see // [`Renderer::with_reads`]) @@ -992,15 +998,42 @@ impl<'a> Renderer<'a> { /// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's /// nominal size; proxies are scaled up to it), converted from its colour profile. - fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf { - let mut buf = match self.footage_layout(img, w, h) { - FootageLayout::Native { scale } => Buf { img: img.clone(), offset: [0.0; 2], scale }, - FootageLayout::Resample { width, height, scale } => Buf { img: effectcraft_raster::resample(img, width, height), offset: [0.0; 2], scale }, - }; - if let Some(c) = self.pipe.media_in(f) { - color::convert(&mut buf.img, &c); + /// + /// `quantized`: also clamp and quantise it to the project depth, as [`Renderer::masked_source`] + /// would (fused with the copy of the cached frame: one pass over it instead of three). + fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32, quantized: bool) -> Buf { + let c = self.pipe.media_in(f); + match self.footage_layout(img, w, h) { + FootageLayout::Native { scale } => { + let img = match (quantized, &c) { + (true, _) => self.pipe.copy_converted(img, c.as_ref()), + (false, Some(c)) => { + let mut img = img.clone(); + color::convert(&mut img, c); + img + } + (false, None) => img.clone(), + }; + Buf { img, offset: [0.0; 2], scale } + } + FootageLayout::Resample { width, height, scale } => { + let mut img = effectcraft_raster::resample(img, width, height); + if let Some(c) = &c { + color::convert(&mut img, c); + } + if quantized { + self.pipe.quantize(&mut img); + } + Buf { img, offset: [0.0; 2], scale } + } } - buf + } + + /// The layer's source is footage that [`Renderer::source`] hands over already quantised + /// ([`Renderer::footage_buf`]): footage without masks (masks apply to unquantised pixels). + /// Collapsed layers may be vector footage, drawn elsewhere: not counted. + fn prequantized_footage(layer: &Layer) -> bool { + matches!(layer.source, LayerSource::Footage { .. }) && !layer.switches.collapse && layer.masks().is_none_or(|m| m.groups().next().is_none()) } /// How [`Renderer::footage_buf`] lays out a decoded frame `img` of footage `w`×`h` layer @@ -1159,13 +1192,14 @@ impl<'a> Renderer<'a> { /// Source → masks, clamped/quantised to the project depth. fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { - let mut buf = match self.content_times(ctx, layer) { - Some(times) => self.blurred_source(ctx, layer, ×)?, - None => self.source(ctx, layer)?, + let (mut buf, quantized) = match self.content_times(ctx, layer) { + Some(times) => (self.blurred_source(ctx, layer, ×)?, false), + None => (self.source_with(ctx, layer, true)?, Self::prequantized_footage(layer)), }; - // A solid is filled with a quantised colour: only masks can take it off the grid. + // A solid is filled with a quantised colour, and plain footage comes quantised: only + // masks can take them off the grid. let solid = matches!(layer.source, LayerSource::Solid { .. }); - if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !solid { + if masks::apply(ctx, layer, &mut buf, self.mask_blur(ctx, layer)) || !(solid || quantized) { self.pipe.quantize(&mut buf.img); } Some(buf)