diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index a9fc4665f..371085938 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -388,6 +388,10 @@ impl FootageSource for ConfinedFootage { fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { self.readable(footage).then(|| self.inner.frame_with(item, footage, t, size, float)).flatten() } + /// The frame as 8-bit levels, or `None` when the footage lies outside the allowed roots. + fn frame8(&self, item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + self.readable(footage).then(|| self.inner.frame8(item, footage, t, float)).flatten() + } /// The footage audio from the inner source, or `None` when the footage lies outside the allowed roots. fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option> { self.readable(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten() diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index a2b24b3ae..86fb088f9 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -7,8 +7,9 @@ //! are encoded in the file. Alpha follows the footage's [`AlphaMode`] interpretation. use effectcraft_project::{AlphaMode, Footage}; -use effectcraft_raster::{Image, Px}; +use effectcraft_raster::{Image, Px, quantize_px_u8}; use effectcraft_render::FloatCurve; +use effectcraft_render::disk_cache::Frame8; use filmcraft_color::{Matrix, Range}; use filmcraft_frame::{Chroma, PixelData, VideoFrame}; use rayon::prelude::*; @@ -80,14 +81,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - return img; } match &f.data { - PixelData::Rgba8(d) => { - img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - let src = &d[y * w * 4..(y + 1) * w * 4]; - for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { - *o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]); - } - }); - } + PixelData::Rgba8(d) => convert_rgba8(&mut img.data, d, w, op, |p| p), PixelData::RgbaF32(d) => { // linear premultiplied → encoded premultiplied img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { @@ -104,7 +98,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - let yuv = Yuv::new(f.color.matrix, f.color.range, 8); let amax = 255.0; convert_yuv( - &mut img, + &mut img.data, YuvSource { size: (w, h), chroma: *chroma, @@ -114,13 +108,14 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - }, &yuv, op, + |p| p, ); } PixelData::Yuv16 { planes, chroma, bits, alpha } => { let yuv = Yuv::new(f.color.matrix, f.color.range, *bits); let amax = ((1u32 << *bits) - 1) as f32; convert_yuv( - &mut img, + &mut img.data, YuvSource { size: (w, h), chroma: *chroma, @@ -130,12 +125,52 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - }, &yuv, op, + |p| p, ); } } img } +/// An 8-bit frame as [`frame_to_image_in`] converts it and an 8 bpc render then quantises it +/// ([`effectcraft_raster::quantize_px`]), kept as the 8-bit levels: expanded with +/// [`effectcraft_raster::expand_u8`] it is that quantised image to the bit, in a quarter of +/// the memory. `None` for frames with more than 8 bits (10/12-bit Y'CbCr, float). +pub(crate) fn frame_to_rgba8(f: &VideoFrame, op: AlphaOp) -> Option { + let (w, h) = (f.width as usize, f.height as usize); + let mut px = vec![[0u8; 4]; w * h]; + if w > 0 && h > 0 { + match &f.data { + PixelData::Rgba8(d) => convert_rgba8(&mut px, d, w, op, quantize_px_u8), + PixelData::Yuv8 { planes, chroma, alpha } => convert_yuv( + &mut px, + YuvSource { + size: (w, h), + chroma: *chroma, + planes: [&planes[0][..], &planes[1][..], &planes[2][..]], + alpha: alpha.as_deref().map(|a| &a[..]), + amax: 255.0, + }, + &Yuv::new(f.color.matrix, f.color.range, 8), + op, + quantize_px_u8, + ), + PixelData::RgbaF32(_) | PixelData::Yuv16 { .. } => return None, + } + } + Some(Frame8 { width: f.width, height: f.height, rgba: px.into_flattened() }) +} + +/// Straight or premultiplied 8-bit RGBA into `out` (one `O` per pixel, made by `store`). +fn convert_rgba8(out: &mut [O], d: &[u8], w: usize, op: AlphaOp, store: impl Fn(Px) -> O + Sync) { + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let Some(src) = d.get(y * w * 4..(y + 1) * w * 4) else { return }; + for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { + *o = store(op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize])); + } + }); +} + /// A movie frame turned the way its file says it is shown ([`crate::probe::video_turn`]: /// clockwise quarter turns; anything else leaves it as decoded). pub(crate) fn turned(img: Image, turn: image::metadata::Orientation) -> Image { @@ -311,13 +346,14 @@ struct YuvSource<'a, T> { amax: f32, } -fn convert_yuv(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op: AlphaOp) { +/// Y'CbCr into `out` (one `O` per pixel, made by `store` from the converted pixel). +fn convert_yuv(out: &mut [O], source: YuvSource<'_, T>, k: &Yuv, op: AlphaOp, store: impl Fn(Px) -> O + Sync) { let YuvSource { size: (w, h), chroma, planes, alpha, amax } = source; let (sx, sy) = chroma.shifts(); let cw = w.div_ceil(1 << sx); let ch = h.div_ceil(1 << sy); let inv_amax = 1.0 / amax; - img.data.par_chunks_mut(w).enumerate().for_each_init( + out.par_chunks_mut(w).enumerate().for_each_init( || (vec![0f32; cw], vec![0f32; cw], vec![0f32; w], vec![0f32; w]), |(uc, vc, uf, vf), (y, row)| { // vertical chroma interpolation into normalised rows (Cb, Cr in −0.5..0.5) @@ -357,13 +393,13 @@ fn convert_yuv(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op None => { for (x, o) in row.iter_mut().enumerate() { let c = rgb(x); - *o = [c[0], c[1], c[2], 1.0]; + *o = store([c[0], c[1], c[2], 1.0]); } } Some(al) => { let arow = &al[y * w..y * w + w]; for (x, o) in row.iter_mut().enumerate() { - *o = op.apply(rgb(x), (arow[x].f() * inv_amax).clamp(0.0, 1.0)); + *o = store(op.apply(rgb(x), (arow[x].f() * inv_amax).clamp(0.0, 1.0))); } } } @@ -552,6 +588,43 @@ mod tests { assert!((p[0] - 0.5).abs() < 1e-3 && (p[1] - 1.0).abs() < 1e-5 && p[2] > 1.5, "{p:?}"); } + /// The 8-bit levels of a frame expand to the float frame quantised to 8 bpc, bit for bit: + /// 4:2:0 and 4:4:4 Y'CbCr with and without alpha, and RGBA, under every alpha interpretation. + #[test] + fn rgba8_levels_are_the_quantised_float_frame() { + let (w, h) = (37usize, 18usize); + let noise = |n: usize, seed: usize| Arc::new((0..n).map(|i| ((i * 7919 + seed * 104_729) % 256) as u8).collect::>()); + let frame = |data| VideoFrame { + width: w as u32, + height: h as u32, + data, + color: filmcraft_color::ColorInfo::REC709, + par: (1, 1), + pts: filmcraft_time::Tick::ZERO, + }; + let yuv = |chroma: Chroma, alpha: bool| { + let n = w.div_ceil(1 << chroma.shifts().0) * h.div_ceil(1 << chroma.shifts().1); + frame(PixelData::Yuv8 { planes: [noise(w * h, 1), noise(n, 2), noise(n, 3)], chroma, alpha: alpha.then(|| noise(w * h, 4)) }) + }; + let frames = [yuv(Chroma::C420, false), yuv(Chroma::C420, true), yuv(Chroma::C444, true), frame(PixelData::Rgba8(noise(w * h * 4, 5)))]; + for f in &frames { + for mode in [AlphaMode::Straight, AlphaMode::Premultiplied, AlphaMode::Ignore] { + let op = AlphaOp::new(mode, [0.2, 0.5, 0.9]); + let q8 = frame_to_rgba8(f, op).expect("8-bit"); + let mut want = frame_to_image(f, op); + for p in &mut want.data { + effectcraft_raster::quantize_px(p, 255.0, 1.0 / 255.0); + } + assert_eq!((q8.width, q8.height), (want.width, want.height)); + for (got, want) in q8.rgba.as_chunks::<4>().0.iter().zip(&want.data) { + assert_eq!(effectcraft_raster::expand_u8(*got).map(f32::to_bits), want.map(f32::to_bits)); + } + } + } + let wide = frame(PixelData::RgbaF32(Arc::new(vec![0.5; w * h * 4]))); + assert!(frame_to_rgba8(&wide, AlphaOp::new(AlphaMode::Straight, [0.0; 3])).is_none()); + } + /// `cargo test -p effectcraft-media --lib bench -- --ignored --nocapture` #[test] #[ignore] diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index cd6676220..e35ff37fc 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -6,13 +6,14 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock}; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId}; use effectcraft_raster::{Image, Px}; +use effectcraft_render::disk_cache::Frame8; use effectcraft_render::reads::{FileStamp, ReadFile, ReadFiles, ReadLog}; use effectcraft_render::{FloatCurve, FootageSource}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_media::{FrameRequest, SharedSource}; use image::metadata::Orientation; -use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, turned}; +use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in, frame_to_image_scaled, frame_to_rgba8, turned}; use crate::downmix::Order; use crate::gif_anim::{self, Anim}; use crate::reads; @@ -53,10 +54,28 @@ struct Key { /// resampled (see `Inner::still_at_size`); also at its own size for a placed PDF / /// Illustrator page, which is drawn to fill it ([`Footage::placed_page`]); (0, 0) otherwise. nominal: (u32, u32), + /// Kept as 8-bit levels ([`FootageSource::frame8`]), not float. + q8: bool, +} + +/// A cached frame's pixels: float, or the 8-bit levels an 8 bpc render quantises it to. +#[derive(Clone)] +enum Pixels { + F32(Arc), + Q8(Arc), +} + +impl Pixels { + fn bytes(&self) -> usize { + match self { + Pixels::F32(img) => img.data.len() * std::mem::size_of::(), + Pixels::Q8(f) => f.rgba.len(), + } + } } struct Entry { - img: Arc, + img: Pixels, /// The files the frame was decoded from (see [`crate::reads`]). files: Arc, stamp: u64, @@ -112,8 +131,8 @@ struct Lru { } impl Lru { - /// The cached image and its read files for `k`, marking the entry as most recently used. - fn get(&mut self, k: &Key) -> Option<(Arc, Arc)> { + /// The cached frame and its read files for `k`, marking the entry as most recently used. + fn get(&mut self, k: &Key) -> Option<(Pixels, Arc)> { let e = self.map.get_mut(k)?; self.order.remove(&e.stamp); self.clock += 1; @@ -121,9 +140,9 @@ impl Lru { self.order.insert(e.stamp, k.clone()); Some((e.img.clone(), e.files.clone())) } - /// Stores an image with its read files under `k` and evicts the least recently used entries until the cache fits `budget` bytes. - fn insert(&mut self, k: Key, img: Arc, files: Arc, budget: usize) { - let bytes = img.data.len() * std::mem::size_of::() + 64; + /// Stores a frame with its read files under `k` and evicts the least recently used entries until the cache fits `budget` bytes. + fn insert(&mut self, k: Key, img: Pixels, files: Arc, budget: usize) { + let bytes = img.bytes() + 64; self.clock += 1; if let Some(old) = self.map.insert(k.clone(), Entry { img, files, stamp: self.clock, bytes }) { self.order.remove(&old.stamp); @@ -145,8 +164,10 @@ impl Lru { let Some((_, k)) = self.order.pop_first() else { break }; if let Some(e) = self.map.remove(&k) { self.bytes -= e.bytes; + // ponytail: float buffers only; add 8-bit spares if their allocation shows up in a profile if self.spare.len() < MAX_SPARE - && let Ok(img) = Arc::try_unwrap(e.img) + && let Pixels::F32(img) = e.img + && let Ok(img) = Arc::try_unwrap(img) { self.spare.push(img.data); } @@ -185,6 +206,8 @@ struct CacheState { inflight: HashSet, /// Last frame index requested per movie (sequential-playback detection). last: HashMap, i64>, + /// Movies whose frames can't be kept as 8-bit levels (more bits, or shown turned). + wide: HashSet>, } /// A movie/audio source by path ([`Opened`]), opened by the first caller while the others wait @@ -407,6 +430,7 @@ impl MediaPool { c.lru.remove(&k); } c.last.remove(path); + c.wide.remove(path); drop(c); crate::exr_channels::forget(path); } @@ -422,6 +446,21 @@ impl MediaPool { Inner::frame_at(&self.inner, footage, t, true, None, FloatCurve::Srgb) } + /// [`FootageSource::frame8`]: `None` (the renderer then asks for the float frame) for + /// anything but an 8-bit movie frame shown as decoded, and on errors. + fn frame8_of(&self, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + if footage.missing || footage.kind != FootageKind::Video || gif_anim::is_animated(footage) { + return None; + } + if lock(&self.inner.cache).wide.contains(footage.path.as_str()) { + return None; + } + match Inner::frame_px(&self.inner, footage, t, true, None, float, true) { + Ok(Pixels::Q8(f)) => Some(f), + _ => None, + } + } + /// [`FootageSource::frame`] / [`FootageSource::frame_at_size`]: `None` (logged) on errors. fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { if footage.missing { @@ -775,7 +814,7 @@ impl Inner { } << 2) | (u8::from(footage.placed_page) << 4); let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] }; - let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) }; + let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0), q8: false }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); @@ -811,10 +850,22 @@ impl Inner { /// The frame of `footage` at `t`, resampled to `size` (see [`FootageSource::frame_at_size`]): /// a movie's made at that size directly, a still's resampled once and kept at that size. fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result> { + match Self::frame_px(self, footage, t, playback, size, float, false)? { + Pixels::F32(img) => Ok(img), + // (a float request's key never holds 8-bit levels) + Pixels::Q8(_) => Err(MediaError::Decode(format!("{}: 8-bit frame for a float request", footage.path))), + } + } + + /// [`Self::frame_at`], kept as 8-bit levels when `q8` is set and the frame has 8 bits (a + /// float frame under the float key otherwise, and the movie is remembered as `wide`). + #[allow(clippy::too_many_arguments)] + fn frame_px(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve, q8: bool) -> Result { if !footage.has_video { return Err(MediaError::Unsupported(format!("{}: no video", footage.path))); } let mut loc = Self::locate(footage, t, float); + loc.key.q8 = q8; let movie = loc.media_t.is_some(); // A still asked for at its own size is the unsized frame (one decode, one entry). if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) { @@ -854,7 +905,7 @@ impl Inner { drop(c); let (img, files) = reads::captured(|| self.decode(&loc, footage)); reads::note_all(&files); - return Ok(Arc::new(img?)); + return img; } c = self.done.wait(c).unwrap_or_else(std::sync::PoisonError::into_inner); } @@ -864,8 +915,14 @@ impl Inner { let _guard = Inflight { inner: self, key: loc.key.clone() }; let (img, files) = reads::captured(|| self.decode(&loc, footage)); reads::note_all(&files); - let img = Arc::new(img?); - lock(&self.cache).lru.insert(loc.key, img.clone(), Arc::new(files), self.budget()); + let img = img?; + let mut c = lock(&self.cache); + if q8 && let Pixels::F32(_) = img { + // not 8-bit: kept as the float frame it is, which the renderer asks for next + c.wide.insert(loc.key.path.clone()); + loc.key.q8 = false; + } + c.lru.insert(loc.key, img.clone(), Arc::new(files), self.budget()); Ok(img) } @@ -876,24 +933,25 @@ impl Inner { let next_t = t + footage.frame_rate.frame_duration(); let mut next = Self::locate(footage, next_t, float); next.key.size = loc.key.size; + next.key.q8 = loc.key.q8; if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { - self.prefetch(footage, next_t, size, float); + self.prefetch(footage, next_t, size, float, loc.key.q8); } } } #[cfg(not(target_arch = "wasm32"))] - fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { + fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve, q8: bool) { let (me, f) = (self.clone(), footage.clone()); self.prefetched.fetch_add(1, Ordering::Relaxed); // A plain thread, not a rayon job: the decode below parallelises with rayon itself. std::thread::spawn(move || { - let _ = me.frame_at(&f, t, false, size, float); + let _ = me.frame_px(&f, t, false, size, float, q8); }); } #[cfg(target_arch = "wasm32")] - fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} + fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve, _q8: bool) {} /// A still (image file or sequence frame) at `loc.key.size` when one is set: resampled as the /// renderer would ([`FootageSource::frame_at_size`]), so the pool keeps the reduced frame and a @@ -910,9 +968,9 @@ impl Inner { effectcraft_raster::resample(&img, w, h) } - fn decode(&self, loc: &Loc, footage: &Footage) -> Result { + fn decode(&self, loc: &Loc, footage: &Footage) -> Result { match loc.media_t { - None => self.decode_still(loc, footage).map(|img| Self::still_at_size(img, loc, footage)), + None => self.decode_still(loc, footage).map(|img| Pixels::F32(Arc::new(Self::still_at_size(img, loc, footage)))), Some(_) => self.decode_media(loc, footage), } } @@ -959,15 +1017,16 @@ impl Inner { } } - fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result { + fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result { let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; + let f32 = |img| Pixels::F32(Arc::new(img)); match loc.media_t { - None => self.decode_still(loc, footage), + None => self.decode_still(loc, footage).map(f32), Some(mt) if gif_anim::is_animated(footage) => { let anim = self.gif(path)?; let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?; - Ok(dynamic_to_image(frame, op)) + Ok(f32(dynamic_to_image(frame, op))) } Some(mt) => { let Opened { src, turn, .. } = self.opened(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; @@ -985,10 +1044,16 @@ impl Inner { && (vf.width, vf.height) == side((footage.width, footage.height)) && let Some(img) = frame_to_image_scaled(&vf, w, h) { - return Ok(turned(img, turn)); + return Ok(f32(turned(img, turn))); + } + if loc.key.q8 + && turn == Orientation::NoTransforms + && let Some(f) = frame_to_rgba8(&vf, op) + { + return Ok(Pixels::Q8(Arc::new(f))); } let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize); - Ok(turned(frame_to_image_in(&vf, op, buf), turn)) + Ok(f32(turned(frame_to_image_in(&vf, op, buf), turn))) } } } @@ -1038,6 +1103,9 @@ impl FootageSource for MediaPool { fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { self.frame_sized(item, footage, t, size, float) } + fn frame8(&self, _item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + self.frame8_of(footage, t, float) + } fn problem(&self, footage: &Footage) -> Option { if footage.missing || footage.kind != FootageKind::Video { @@ -1275,8 +1343,8 @@ mod tests { #[test] fn lru_respects_budget_and_recycles() { let mut c = Lru::default(); - let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead - let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) }; + let img = || Pixels::F32(Arc::new(Image::new(16, 16))); // 4 KiB + overhead + let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0), q8: false }; let budget = 3 * (4096 + 64); for i in 0..10 { c.insert(k(i), img(), Arc::default(), budget); @@ -1292,6 +1360,18 @@ mod tests { assert!(c.take_spare(17).is_empty()); } + /// A frame kept as 8-bit levels is its own entry beside the float one, counted at 4 bytes + /// a pixel. + #[test] + fn lru_keeps_8_bit_frames_apart_at_a_quarter_of_the_bytes() { + let mut c = Lru::default(); + let k = |q8| Key { path: "a".into(), frame: 0, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0), q8 }; + c.insert(k(false), Pixels::F32(Arc::new(Image::new(16, 16))), Arc::default(), usize::MAX); + c.insert(k(true), Pixels::Q8(Arc::new(Frame8 { width: 16, height: 16, rgba: vec![0; 16 * 16 * 4] })), Arc::default(), usize::MAX); + assert!(matches!(c.get(&k(false)), Some((Pixels::F32(_), _))) && matches!(c.get(&k(true)), Some((Pixels::Q8(_), _)))); + assert_eq!(c.bytes, (16 * 16 * 16 + 64) + (16 * 16 * 4 + 64)); + } + /// #482: an OpenEXR file with HTJ2K compression (which the decoder lacks) says so on import /// and when its frame is read, instead of "no non-deep rgb channels" or a transparent frame. #[test] diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index fd2fd183b..e18a24c63 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -31,15 +31,36 @@ pub const MAX_PADDED_SIDE: u32 = 30_000; /// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels): /// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer /// conversion rounds like `round()` and vectorises. -#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN #[inline(always)] pub fn quantize_px(p: &mut Px, levels: f32, inv: f32) { - let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv; + *p = quantize_levels(*p, levels, inv).map(|n| n * inv); +} + +/// [`quantize_px`]'s steps: the whole number of `levels` each channel rounds to (0..=levels). +#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN +#[inline(always)] +pub fn quantize_levels(p: Px, levels: f32, inv: f32) -> [f32; 4] { + let na = round_half_up(p[3].max(0.0).min(1.0) * levels); + let a = na * inv; let hi = [a, a, a, a]; + let mut n = [0.0, 0.0, 0.0, na]; for c in 0..3 { - p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv; + n[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels); } - p[3] = a; + n +} + +/// [`quantize_px`] at 8 bpc, as bytes: [`expand_u8`] gives back exactly the pixel +/// [`quantize_px`] writes, so an 8 bpc buffer can be kept in a quarter of the memory. +#[inline(always)] +pub fn quantize_px_u8(p: Px) -> [u8; 4] { + quantize_levels(p, 255.0, 1.0 / 255.0).map(|n| n as u8) +} + +/// The 8 bpc pixel of [`quantize_px_u8`]'s bytes. +#[inline(always)] +pub fn expand_u8(n: [u8; 4]) -> Px { + n.map(|v| f32::from(v) * (1.0 / 255.0)) } /// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's @@ -403,6 +424,23 @@ impl Mask { mod tests { use super::*; + /// Pixels kept as `quantize_px_u8` bytes expand to the 8 bpc pixel `quantize_px` writes, bit + /// for bit: every level, exact halves, out-of-range, negative, NaN and premultiplied colour + /// brighter than its alpha. + #[test] + fn u8_levels_expand_to_the_quantised_pixel() { + let mut values: Vec = (0..=255 * 4).map(|i| i as f32 / (255.0 * 4.0)).collect(); + values.extend([-0.0, -1.0, 1.0, 1.5, f32::NAN, f32::INFINITY, 0.5 / 255.0, 254.5 / 255.0]); + for &a in &[1.0, 0.5, 0.2, 0.0, 2.0, f32::NAN] { + for &v in &values { + let p = [v, 1.0 - v, v * 0.5, a]; + let mut want = p; + quantize_px(&mut want, 255.0, 1.0 / 255.0); + assert_eq!(expand_u8(quantize_px_u8(p)).map(f32::to_bits), want.map(f32::to_bits), "{p:?}"); + } + } + } + #[test] fn dither_keeps_exact_codes_and_averages_the_rest() { // A value on an 8-bit code is that code at every position. diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index 6b7fdcbf8..c14961d0f 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -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> { + 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> { self.read(|| self.inner.frame_with(item, footage, t, size, float)) } + fn frame8(&self, item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option> { + 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 diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 6979afc85..fd46260c2 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -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 { + 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. diff --git a/crates/render/src/tests/footage_size.rs b/crates/render/src/tests/footage_size.rs index 065941d8a..25c1a1814 100644 --- a/crates/render/src/tests/footage_size.rs +++ b/crates/render/src/tests/footage_size.rs @@ -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> { + 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> { + 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> { + 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}"); + } +}