mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
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:
+52
-27
@@ -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")))?;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user