G5: stills at a reduced render size are resampled once and kept at that size (#1812)

The media pool honoured the requested size only for movies; stills came back
at full size and the renderer resampled them on every frame. A 21120 px wide
plate at Quarter was re-filtered each frame, and four of them (1.85 GB as f32)
did not fit the 1 GiB pool, so they were also decoded again and again.

Stills are now resampled once with effectcraft_raster::resample (the same
call the renderer made, so pixels are unchanged) and the pool keeps the
reduced copy under the sized key.

#1812 project at Quarter, CLI bench: 410 ms -> 33 ms a frame (26 ms with the
layer cache); desktop playback 2% -> 60-99% of real time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ded
2026-10-20 03:43:33 +03:00
co-authored by Claude Opus 5.5
parent ef74d82902
commit 57326dc6a7
2 changed files with 78 additions and 27 deletions
+52 -27
View File
@@ -708,15 +708,15 @@ impl Inner {
}
}
/// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that
/// size directly (see [`FootageSource::frame_at_size`]).
/// 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>> {
if !footage.has_video {
return Err(MediaError::Unsupported(format!("{}: no video", footage.path)));
}
let mut loc = Self::locate(footage, t, float);
let movie = loc.media_t.is_some();
if movie && let Some(size) = size {
if let Some(size) = size {
loc.key.size = size;
}
let mut c = lock(&self.cache);
@@ -773,34 +773,59 @@ impl Inner {
#[cfg(target_arch = "wasm32")]
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {}
/// A still (image file or sequence frame) of the footage's own size, 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 reduced-resolution render neither decodes nor resamples
/// a large still again for every frame (#1812: 21120-px plates at Quarter).
fn still_at_size(img: Image, loc: &Loc, footage: &Footage) -> Image {
let (w, h) = loc.key.size;
if w == 0 || h == 0 || (img.width, img.height) != (footage.width, footage.height) || (w, h) == (img.width, img.height) {
return img;
}
effectcraft_raster::resample(&img, w, h)
}
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
match loc.media_t {
None => self.decode_still(loc, footage).map(|img| Self::still_at_size(img, loc, footage)),
Some(_) => self.decode_media(loc, footage),
}
}
fn decode_still(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
let op = AlphaOp::of(footage, loc.float);
let path = &loc.key.path;
if &**path == PLACEHOLDER {
return Ok(colour_bars(footage.width, footage.height));
}
let bytes = self.read(path)?;
if let Some(img) = crate::dpx::decode(path, &bytes)? {
return Ok(dynamic_to_image(&img, op));
}
if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
return Ok(img);
}
// (an OpenEXR compression the decoder lacks came out transparent or as a missing
// channel error)
if path.to_ascii_lowercase().ends_with(".exr")
&& let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
let img = match loaded {
Ok(img) => img,
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
};
Ok(dynamic_to_image(&img, op))
}
fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
let op = AlphaOp::of(footage, loc.float);
let path = &loc.key.path;
match loc.media_t {
None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)),
None => {
let bytes = self.read(path)?;
if let Some(img) = crate::dpx::decode(path, &bytes)? {
return Ok(dynamic_to_image(&img, op));
}
if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
return Ok(img);
}
// (an OpenEXR compression the decoder lacks came out transparent or as a missing
// channel error)
if path.to_ascii_lowercase().ends_with(".exr")
&& let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
let img = match loaded {
Ok(img) => img,
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
};
Ok(dynamic_to_image(&img, op))
}
None => self.decode_still(loc, footage),
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")))?;
+26
View File
@@ -580,6 +580,32 @@ fn sequence_gaps_show_placeholders() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #1812: a still asked for at a reduced size comes back as the renderer would resample it, and
/// is kept at that size: later frames at that size neither decode nor resample it again (a
/// parallax project's 21120-px plates were decoded and resampled for every frame at Quarter).
#[test]
fn stills_at_reduced_sizes_are_resampled_once() {
let dir = std::env::temp_dir().join(format!("ec-still-size-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let p = dir.join("plate.png");
let px = |x: u32, y: u32| {
image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, ((x ^ y) % 256) as u8, if (x + y).is_multiple_of(5) { 0 } else { 255 - (x % 128) as u8 }])
};
image::RgbaImage::from_fn(613, 97, px).save(&p).expect("png");
let f = probe(&p).expect("probe");
let pool = MediaPool::new();
let full = pool.frame(ItemId(1), &f, Tick::ZERO).expect("frame");
for (w, h) in [(306, 48), (153, 24), (77, 12)] {
let sized = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, w, h).expect("frame");
assert_eq!(*sized, effectcraft_raster::resample(&full, w, h), "{w}×{h}: the renderer's resample");
let misses = pool.stats().misses;
let again = pool.frame_at_size(ItemId(1), &f, Tick(3 * TICKS_PER_SECOND), w, h).expect("frame");
assert!(Arc::ptr_eq(&sized, &again), "{w}×{h}: kept at that size");
assert_eq!(pool.stats().misses, misses, "{w}×{h}: not decoded again");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn png_with_alpha() {
let Some(p) = alpha_png() else { return };