security(engine): bound graphic template thumbnails (#2887)

* security(engine): bound graphic template thumbnails

* graphic templates: scale a capped thumbnail to fit both sides of the canvas

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Earl Co
2026-10-23 10:52:30 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent ebe15de141
commit 9722d77aa5
2 changed files with 24 additions and 3 deletions
+17 -3
View File
@@ -44,6 +44,7 @@ fn query(id: &'static str, label: &'static str, params: &'static str, run: Run)
}
const G: &[&str] = &["Graphics and Titles"];
const MAX_THUMBNAIL_DIMENSION: u32 = 1920;
fn has_graphic(s: &Session) -> std::result::Result<(), String> {
has_seq(s)?;
@@ -149,8 +150,7 @@ fn entry_json(e: &LibraryEntry) -> Value {
/// Render a template's thumbnail (static: rolls are shown at rest) as straight sRGB RGBA8,
/// `w` pixels wide with the template's aspect ratio. Returns (width, height, pixels).
pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec<u8>) {
let w = w.clamp(16, 1920);
let h = ((w as f64 * t.canvas[1] as f64 / t.canvas[0].max(1) as f64).round() as u32).max(1);
let (w, h) = thumbnail_dimensions(t.canvas, w);
register_fonts(t);
let mut meta = t.graphic.clone();
meta.roll.mode = RollMode::Off;
@@ -171,11 +171,25 @@ pub fn thumbnail(t: &GraphicsTemplate, w: u32) -> (u32, u32, Vec<u8>) {
// a moment into the clip, so intro animations have settled a little
let at = Tick((t.duration.0 / 2).min(filmcraft_time::TICKS_PER_SECOND));
let mut img = filmcraft_render::image::Image::new(w as usize, h as usize);
let k = w as f64 / t.canvas[0].max(1) as f64;
// fit both sides: a capped height leaves `w / canvas width` too large for a tall canvas
let k = (w as f64 / t.canvas[0].max(1) as f64).min(h as f64 / t.canvas[1].max(1) as f64);
filmcraft_render::graphic_clip::render_graphic(&it, at, (t.canvas[0], t.canvas[1]), &Affine::scale(k, k), &mut img);
(w, h, img.to_rgba8())
}
/// Fit a requested thumbnail width within a bounded rectangle without trusting the template's
/// aspect ratio to derive an unbounded second dimension.
fn thumbnail_dimensions(canvas: [u32; 2], requested_width: u32) -> (u32, u32) {
let mut w = requested_width.clamp(16, MAX_THUMBNAIL_DIMENSION);
let aspect = canvas[0].max(1) as f64 / canvas[1].max(1) as f64;
let mut h = (w as f64 / aspect).round().clamp(1.0, u32::MAX as f64) as u32;
if h > MAX_THUMBNAIL_DIMENSION {
h = MAX_THUMBNAIL_DIMENSION;
w = (h as f64 * aspect).round().clamp(1.0, MAX_THUMBNAIL_DIMENSION as f64) as u32;
}
(w, h)
}
/// Cached thumbnails by template id + content + width (the UI asks every frame).
pub fn thumbnail_cached(t: &GraphicsTemplate, w: u32) -> Arc<(u32, u32, Vec<u8>)> {
static CACHE: OnceLock<Mutex<std::collections::HashMap<(String, u64, u32), Arc<(u32, u32, Vec<u8>)>>>> = OnceLock::new();
@@ -143,6 +143,13 @@ fn builtin_templates_apply_and_thumbnail() {
}
}
#[test]
fn template_thumbnail_dimensions_fit_a_bounded_rectangle() {
assert_eq!(super::thumbnail_dimensions([1920, 1080], 160), (160, 90));
assert_eq!(super::thumbnail_dimensions([1, 16_384], 1920), (1, 1920));
assert_eq!(super::thumbnail_dimensions([16_384, 1], 1920), (1920, 1));
}
#[test]
fn responsive_time_protects_intro_and_outro_when_trimmed() {
let mut s = demo("gt-time");