diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 2dd8a3e7f..c3740196c 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -103,15 +103,22 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f); - // Point-sample down to at most `max_side` on the long side. - let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; - let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); + // Vector footage is drawn at the thumbnail's size: its declared size can be a + // 16384² canvas, gigabytes of pixels to point-sample down to a few dozen. + let scale = (max_side.max(1) as f64 / f.width.max(f.height).max(1) as f64).min(1.0); + let src = footage.vector_frame(item, &f, scale).or_else(|| footage.frame(item, &f, Tick(0)))?; + let img = effectcraft_render::cineon_frame(src, &f); + // Point-sample down to at most `max_side` on the long side (a drawn size one + // pixel over it, from rounding, is shrunk by that pixel, not halved). + let (long, max) = (u64::from(img.width.max(img.height)), u64::from(max_side.max(1))); + let fit = |side: u32| if long <= max { side } else { ((u64::from(side) * max + long / 2) / long).max(1) as u32 }; + let (w, h) = (fit(img.width), fit(img.height)); let full = img.to_rgba8(); let mut px = Vec::with_capacity((w * h * 4) as usize); for y in 0..h { for x in 0..w { - let i = (((y * step) * img.width + x * step) * 4) as usize; + let (sx, sy) = (u64::from(x) * u64::from(img.width) / u64::from(w), u64::from(y) * u64::from(img.height) / u64::from(h)); + let i = ((sy * u64::from(img.width) + sx) * 4) as usize; px.extend_from_slice(&full[i..i + 4]); } } diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 10c07c2ad..2f41b16b2 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -383,3 +383,58 @@ fn psd_with_damaged_layer_section_imports_the_flattened_image() { let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() }; assert!(f.layer.is_none(), "the merged image, not a Photoshop layer"); } + +/// A vector footage thumbnail is drawn at the thumbnail's size, never at its declared size. +#[test] +fn vector_footage_thumbnails_are_drawn_at_thumbnail_size() { + use std::sync::{Arc, Mutex}; + + use effectcraft_project::{Footage, ItemId}; + use effectcraft_raster::Image; + use effectcraft_time::Tick; + + struct Vector(Mutex>); + impl crate::render::FootageSource for Vector { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + panic!("the full-size frame was decoded"); + } + fn vector_frame(&self, _: ItemId, f: &Footage, scale: f64) -> Option> { + self.0.lock().unwrap().push(scale); + Some(Arc::new(Image::new((f.width as f64 * scale).ceil() as u32, (f.height as f64 * scale).ceil() as u32))) + } + } + let source = Arc::new(Vector(Mutex::new(vec![]))); + let mut s = Session { footage: source.clone(), ..Default::default() }; + let f = Footage { path: "/m/huge.svg".into(), codec: "SVG".into(), width: 16_000, height: 8_000, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("huge.svg", effectcraft_color::Label::Sandstone, None, ItemKind::Footage(f)); + let (w, h, _) = (s.thumbnail_job(item, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!((w, h), (64, 32)); + assert_eq!(*source.0.lock().unwrap(), [64.0 / 16_000.0]); +} + +/// A vector footage thumbnail whose drawn size is a pixel over the requested one is shrunk by +/// that pixel, not halved by an integer step. +#[test] +fn a_vector_thumbnail_one_pixel_over_is_not_halved() { + use std::sync::Arc; + + use effectcraft_project::{Footage, ItemId}; + use effectcraft_raster::Image; + use effectcraft_time::Tick; + + struct Over; + impl crate::render::FootageSource for Over { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn vector_frame(&self, _: ItemId, _: &Footage, _: f64) -> Option> { + Some(Arc::new(Image::new(64, 64))) + } + } + let mut s = Session { footage: Arc::new(Over), ..Default::default() }; + let f = Footage { path: "/m/sq.svg".into(), codec: "SVG".into(), width: 121, height: 121, has_video: true, ..Default::default() }; + let item = std::sync::Arc::make_mut(&mut s.project).add_item("sq.svg", effectcraft_color::Label::Sandstone, None, ItemKind::Footage(f)); + let (w, h, px) = (s.thumbnail_job(item, Tick::ZERO, 63).unwrap())().unwrap(); + assert_eq!((w, h), (63, 63)); + assert_eq!(px.len(), 63 * 63 * 4); +}