Footage: 8 bpc renders keep plain movie frames as 8-bit levels

An 8 bpc render at Full quantises plain footage straight away, so the
media pool now keeps those movie frames as the 8-bit levels it is
quantised to (a quarter of the memory) and the renderer expands them
into the layer buffer: the same pixels to the bit. Other depths,
reduced renders, masks, colour profiles, fields, frame blending,
Cineon, Invert Alpha, linear light and 10/12-bit or float movies keep
the float path.

First playback of plain 1080p clips at Full caches ~43% faster on the
CPU, and the footage cache holds 4x the frames.
This commit is contained in:
nazar-gavrylyk committed 2026-11-10 15:03:10 +01:00
1 parent 50a889737e
commit 76e4008151
7 files changed
+334 -44

No files matched your search

+11
View File
@@ -37,6 +37,14 @@ pub trait FootageSource: Send + Sync {
None => self.frame(item, footage, t),
}
}
/// [`FootageSource::frame_with`]'s full-size frame as 8-bit levels: each pixel is
/// [`effectcraft_raster::quantize_px_u8`] of the float pixel `frame_with` would give, so
/// [`effectcraft_raster::expand_u8`] makes that frame quantised to 8 bpc, bit for bit, from a
/// quarter of the memory (an 8 bpc render of 8-bit footage). `None` when the source has no
/// cheaper way than the float frame; the renderer then takes that.
fn frame8(&self, _item: ItemId, _footage: &Footage, _t: Tick, _float: FloatCurve) -> Option<Arc<crate::disk_cache::Frame8>> {
None
}
/// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at
/// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`;
/// a sample boundary uses the earliest representable tick that selects that sample.
@@ -215,6 +223,9 @@ impl FootageSource for CancellableFootage<'_> {
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
self.read(|| self.inner.frame_with(item, footage, t, size, float))
}
fn frame8(&self, item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option<Arc<crate::disk_cache::Frame8>> {
self.read(|| self.inner.frame8(item, footage, t, float))
}
}
/// Footage that can be rasterised at any scale: SVG, PDF, Illustrator and EPS files, and a
+33
View File
@@ -964,6 +964,10 @@ impl<'a> Renderer<'a> {
return Some(buf);
}
}
// 8 bpc plain footage: made from the footage's 8-bit levels, no float frame.
if quantized && let Some(buf) = self.footage_buf8(ctx, layer, item, self.proxy_for(item).unwrap_or(f), f, ctx.source_time(layer)) {
return Some(buf);
}
let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?;
Some(self.footage_buf(&img, pf, f.width, f.height, quantized && Self::prequantized_footage(layer)))
}
@@ -1029,6 +1033,35 @@ impl<'a> Renderer<'a> {
}
}
/// [`Renderer::footage_buf`]'s quantised buffer made from the footage's 8-bit levels
/// ([`FootageSource::frame8`]), the same pixels to the bit, when nothing before the quantise
/// changes them: an 8 bpc render at Full size of plain footage (no masks, colour profile,
/// Separate Fields, Frame Blending, Cineon, Invert Alpha or linear light interpretation).
/// `None` otherwise, and when the source has no 8-bit frame.
#[allow(clippy::too_many_arguments)]
fn footage_buf8(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, pf: &Footage, f: &Footage, t: Tick) -> Option<Buf> {
let plain = self.pipe.levels == Some(255.0)
&& Self::prequantized_footage(layer)
&& self.reduced_size(f.width, f.height).is_none()
&& self.pipe.media_in(pf).is_none()
&& pf.fields == effectcraft_project::FieldOrder::Off
&& !(pf.invert_alpha || pf.linear_light_applies() || pf.cineon_applies())
&& frame_blend_mode(ctx, layer) == FrameBlend::Off;
if !plain {
return None;
}
let fr = self.footage.frame8(item, pf, t, self.pipe.float_curve(pf))?;
let n = (fr.width as usize).checked_mul(fr.height as usize)?;
let px = fr.rgba.as_chunks::<4>().0;
if px.len() != n {
return None;
}
use rayon::prelude::*;
let data = px.par_iter().map(|&p| effectcraft_raster::expand_u8(p)).collect();
let k = if fr.width > 0 { f.width.max(1) as f64 / fr.width as f64 } else { 1.0 };
Some(Buf { img: Image { width: fr.width, height: fr.height, data }, offset: [0.0; 2], scale: 1.0 / k })
}
/// 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.
+51
View File
@@ -106,3 +106,54 @@ fn still_layers_are_cached_across_times_and_movies_are_not() {
assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}");
}
}
/// [`Clip`] that also keeps its frames as 8-bit levels, counting the times it's asked for them.
struct Clip8 {
asked: std::sync::atomic::AtomicUsize,
}
impl FootageSource for Clip8 {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
let mut img = (*Clip { sizes: None }.frame(item, f, t)?).clone();
// (partly transparent, premultiplied, as decoded with alpha)
img.data.iter_mut().enumerate().for_each(|(i, p)| {
let a = (i % 7) as f32 / 6.0;
*p = [p[0] * a, p[1] * a, p[2] * a, a];
});
Some(Arc::new(img))
}
fn frame8(&self, item: ItemId, f: &Footage, t: Tick, _: crate::FloatCurve) -> Option<Arc<crate::disk_cache::Frame8>> {
self.asked.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let img = self.frame(item, f, t)?;
let rgba = img.data.iter().flat_map(|p| effectcraft_raster::quantize_px_u8(*p)).collect();
Some(Arc::new(crate::disk_cache::Frame8 { width: img.width, height: img.height, rgba }))
}
}
/// An 8 bpc render at Full takes plain footage as 8-bit levels and renders the same pixels to
/// the bit; other depths, reduced renders and interpreted pixels never ask for them.
#[test]
fn footage_kept_as_8_bit_levels_renders_the_same_pixels() {
struct Float(Clip8);
impl FootageSource for Float {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
self.0.frame(item, f, t)
}
}
for (depth, invert, scale, used) in [
(BitDepth::Bpc8, false, 1.0, true),
(BitDepth::Bpc8, false, 0.75, true),
(BitDepth::Bpc8, false, 0.5, false),
(BitDepth::Bpc8, true, 1.0, false),
(BitDepth::Bpc16, false, 1.0, false),
(BitDepth::Bpc32, false, 1.0, false),
] {
let (p, cid) = project(depth, invert);
let opts = RenderOpts { scale, ..Default::default() };
let q8 = Clip8 { asked: Default::default() };
let frame = Renderer::new(&p, &q8, opts).comp_frame(cid, Tick::ZERO);
let plain = Renderer::new(&p, &Float(Clip8 { asked: Default::default() }), opts).comp_frame(cid, Tick::ZERO);
assert!(frame.data.iter().zip(&plain.data).all(|(a, b)| a.map(f32::to_bits) == b.map(f32::to_bits)), "{depth:?} {invert} {scale}");
assert_eq!(q8.asked.into_inner() > 0, used, "{depth:?} {invert} {scale}");
}
}