From 0f28312befd8f27b28787bb1cae789fe0694c6f7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 21:00:33 +0000 Subject: [PATCH] UI: cap live video decoders and bound slide-show grid thumbnail height --- crates/ui-egui/src/media.rs | 20 ++++++++++++----- crates/ui-egui/src/show/render.rs | 36 +++++++++++++++++++++++++------ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index a5429bc37..75e652375 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -12,6 +12,9 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand use crate::icons::{self, Icon}; +/// Video shapes that get a decoder at once; more play their sound without a picture. +const MAX_VIDEO_FEEDS: usize = 16; + /// Who started a voice: the editor's control bar stops when you leave the slide, the slide show /// manages its own. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -228,11 +231,18 @@ impl MediaHost { }; let source_changed = self.owners.get(&id).is_some_and(|o| o.source != media_key); if clip.video && (source_changed || !self.videos.contains_key(&id)) { - let feed = match VideoFeed::new(bytes.clone()) { - Ok(f) => Some(f), - Err(e) => { - log::info!("video {id}: {e}"); - None + // One decoder and frame queue per video shape: past the cap a shape plays its sound only. + let live = self.videos.iter().filter(|(vid, v)| **vid != id && v.feed.is_some()).count(); + let feed = if live >= MAX_VIDEO_FEEDS { + log::info!("video {id}: more than {MAX_VIDEO_FEEDS} videos playing; showing no picture"); + None + } else { + match VideoFeed::new(bytes.clone()) { + Ok(f) => Some(f), + Err(e) => { + log::info!("video {id}: {e}"); + None + } } }; self.videos.insert(id, Video { feed, frame: None, tex: None }); diff --git a/crates/ui-egui/src/show/render.rs b/crates/ui-egui/src/show/render.rs index d6de7a9ff..29a0c8fb7 100644 --- a/crates/ui-egui/src/show/render.rs +++ b/crates/ui-egui/src/show/render.rs @@ -196,18 +196,40 @@ pub(super) fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Optio ] } +/// Width of a slide-show grid thumbnail, in pixels. +const THUMB_WIDTH: f64 = 320.0; +/// Tallest grid thumbnail, so an extreme portrait slide cannot ask for a huge raster. +const THUMB_MAX_HEIGHT: f64 = 2048.0; + +/// The scale and pixel size of a grid thumbnail of a `width` x `height` slide. +fn thumb_size(width: f64, height: f64) -> (f64, f64, f64) { + let (sw, sh) = (width.max(1.0), height.max(1.0)); + let scale = (THUMB_WIDTH / sw).min(THUMB_MAX_HEIGHT / sh); + (scale, (sw * scale).round().max(1.0), (sh * scale).round().max(1.0)) +} + pub(super) fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usize) -> Option { if show.thumbs.len() >= GRID_CAP { return None; } - let w = 320.0_f64; - let h = (w * doc.slide_size.height / doc.slide_size.width.max(1.0)).round().max(1.0); - let img = deckcraft_render::render_slide( - doc, - i, - &RenderOpts { scale: w / doc.slide_size.width.max(1.0), size: Some((w as u32, h as u32)), ..Default::default() }, - ); + // 320 wide, unless the slide is so tall that this would pass the height limit: fit that box. + let (scale, w, h) = thumb_size(doc.slide_size.width, doc.slide_size.height); + let img = deckcraft_render::render_slide(doc, i, &RenderOpts { scale, size: Some((w as u32, h as u32)), ..Default::default() }); let tex = crate::textures::upload(ctx, format!("show-grid-{i}"), crate::textures::to_color_image(&img, false)); show.thumbs.push((i, tex.clone())); Some(tex) } + +#[cfg(test)] +mod thumb_size_tests { + use super::*; + + #[test] + fn ordinary_slides_are_320_wide_and_extreme_ones_stay_in_the_height_limit() { + let (scale, w, h) = thumb_size(960.0, 540.0); + assert_eq!((w, h), (320.0, 180.0)); + assert!((scale - 320.0 / 960.0).abs() < 1e-12); + let (_, w, h) = thumb_size(1.0, 1.0e9); + assert!(h <= THUMB_MAX_HEIGHT && w >= 1.0); + } +}