Render: plain footage is copied, converted and quantised in one pass

A footage layer's frame was copied from the footage cache, converted from
its colour profile, then quantised to the project depth in masked_source:
three passes over a 33 MB 1080p float frame. Footage without masks now
comes quantised from one fused pass (Pipe::copy_converted); other source()
callers still get unquantised pixels. Bit-identical (tested at 8/16/32 bpc).
This commit is contained in:
nazar-gavrylyk
2026-11-10 15:03:10 +01:00
parent 42989edd8e
commit b3dc49a0cf
2 changed files with 110 additions and 23 deletions
+61 -8
View File
@@ -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::<Vec<_>>();
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]
+49 -15
View File
@@ -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<Buf> {
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<Buf> {
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<Buf> {
let mut buf = match self.content_times(ctx, layer) {
Some(times) => self.blurred_source(ctx, layer, &times)?,
None => self.source(ctx, layer)?,
let (mut buf, quantized) = match self.content_times(ctx, layer) {
Some(times) => (self.blurred_source(ctx, layer, &times)?, 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)