mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 02:41:57 -06:00
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:
@@ -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<Arc<Image>> {
|
||||
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<Arc<effectcraft_engine::render::disk_cache::Frame8>> {
|
||||
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<Vec<f32>> {
|
||||
self.readable(footage).then(|| self.inner.audio(item, footage, t, frames, rate)).flatten()
|
||||
|
||||
+88
-15
@@ -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<Px>) -
|
||||
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<Px>) -
|
||||
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<Px>) -
|
||||
},
|
||||
&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<Px>) -
|
||||
},
|
||||
&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<Frame8> {
|
||||
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<O: Send>(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<T: Sample>(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<T: Sample, O: Send>(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<T: Sample>(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::<Vec<u8>>());
|
||||
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]
|
||||
|
||||
+105
-25
@@ -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<Image>),
|
||||
Q8(Arc<Frame8>),
|
||||
}
|
||||
|
||||
impl Pixels {
|
||||
fn bytes(&self) -> usize {
|
||||
match self {
|
||||
Pixels::F32(img) => img.data.len() * std::mem::size_of::<Px>(),
|
||||
Pixels::Q8(f) => f.rgba.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
img: Arc<Image>,
|
||||
img: Pixels,
|
||||
/// The files the frame was decoded from (see [`crate::reads`]).
|
||||
files: Arc<ReadFiles>,
|
||||
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<Image>, Arc<ReadFiles>)> {
|
||||
/// 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<ReadFiles>)> {
|
||||
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<Image>, files: Arc<ReadFiles>, budget: usize) {
|
||||
let bytes = img.data.len() * std::mem::size_of::<Px>() + 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<ReadFiles>, 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<Key>,
|
||||
/// Last frame index requested per movie (sequential-playback detection).
|
||||
last: HashMap<Arc<str>, i64>,
|
||||
/// Movies whose frames can't be kept as 8-bit levels (more bits, or shown turned).
|
||||
wide: HashSet<Arc<str>>,
|
||||
}
|
||||
|
||||
/// 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<Arc<Frame8>> {
|
||||
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<Arc<Image>> {
|
||||
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<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result<Arc<Image>> {
|
||||
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<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve, q8: bool) -> Result<Pixels> {
|
||||
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<Self>, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) {
|
||||
fn prefetch(self: &Arc<Self>, 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<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {}
|
||||
fn prefetch(self: &Arc<Self>, _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<Image> {
|
||||
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Pixels> {
|
||||
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<Image> {
|
||||
fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result<Pixels> {
|
||||
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<Arc<Image>> {
|
||||
self.frame_sized(item, footage, t, size, float)
|
||||
}
|
||||
fn frame8(&self, _item: ItemId, footage: &Footage, t: Tick, float: FloatCurve) -> Option<Arc<Frame8>> {
|
||||
self.frame8_of(footage, t, float)
|
||||
}
|
||||
|
||||
fn problem(&self, footage: &Footage) -> Option<String> {
|
||||
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]
|
||||
|
||||
@@ -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<f32> = (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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user