From 05eb4c611b737a01318f3bb998d87de66ed5bac2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:57:32 +0300 Subject: [PATCH] UI: keep every texture within the GPU's max texture side Slide show, transition and morph frames, thumbnails, master-view canvas, master/layout thumbnails, Design Ideas previews, presenter-view preview and video frames all upload through textures::upload/replace, which shrinks a picture past max_texture_side (egui panicked on one). Render sizes are fitted to the limit first, so a big screen, extreme DPI or a 1:56 slide doesn't render a picture the GPU can't hold. The master canvas is cropped like the slide canvas. Co-authored-by: Cursor --- crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/media.rs | 4 +- crates/ui-egui/src/panes.rs | 8 +- crates/ui-egui/src/ribbon.rs | 3 +- crates/ui-egui/src/show.rs | 32 ++++---- crates/ui-egui/src/texture_limit_tests.rs | 53 +++++++++++++ crates/ui-egui/src/textures.rs | 95 +++++++++++++++++++++-- crates/ui-egui/src/thumbs.rs | 12 +-- 9 files changed, 184 insertions(+), 33 deletions(-) create mode 100644 crates/ui-egui/src/texture_limit_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c7290b35..14cbe491 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -314,14 +314,18 @@ fn master_tex( visible: deckcraft_render::Region, ) -> Option<(egui::TextureHandle, deckcraft_render::Region)> { let scale = px.0 as f64 / doc.slide_size.width.max(1.0); - let region = deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible); + let region = crate::textures::fit_texture( + deckcraft_render::pasteboard_region(px, scale, deckcraft_render::layout_extent(doc, master, layout), visible), + visible, + crate::textures::max_side(ctx), + ); let img = deckcraft_render::render_layout( doc, master, layout, &deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() }, ); - Some((ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR), region)) + Some((crate::textures::upload(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region)) } fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b40daf7b..5e53cb40 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1165,6 +1165,8 @@ fn js_now() -> f64 { #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod texture_limit_tests; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index f887c2b2..fd018aea 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -209,8 +209,8 @@ impl MediaHost { { let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba); match v.tex.as_mut() { - Some(t) => t.set(img, egui::TextureOptions::LINEAR), - None => v.tex = Some(ctx.load_texture(format!("media-{id}"), img, egui::TextureOptions::LINEAR)), + Some(t) => crate::textures::replace(ctx, t, img), + None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)), } v.frame = Some(f); } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..f8aa0525 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -613,14 +613,16 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { m.theme = th.clone(); } let ppp = ui.ctx().pixels_per_point(); - let px = (w * ppp) as u32; - let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32; + let (px, h) = crate::textures::fit_size( + (((w * ppp) as u32).max(1), (((w * ppp) as f64) * d.slide_size.height / d.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ui.ctx()), + ); let img = deckcraft_render::render_slide( &d, idx, &deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() }, ); - let tex = ui.ctx().load_texture(format!("idea-{}", th.name), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ui.ctx(), format!("idea-{}", th.name), crate::textures::to_color_image(&img, false)); let (r, resp) = ui.allocate_exact_size(vec2(w, w * h as f32 / px.max(1) as f32), Sense::click()); ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); ui.painter().rect_stroke( diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..f2e06424 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -339,8 +339,7 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false)); ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 17ec527e..a0dfcc30 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -233,7 +233,7 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, t let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; let tex = |i: usize, part: SlidePart, name: &str| { let img = deckcraft_render::render_slide_part(doc, i, part, &opts); - ctx.load_texture(name, crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR) + crate::textures::upload(ctx, name, crate::textures::to_color_image(&img, false)) }; [ from.map(|i| tex(i, SlidePart::Backdrop, "trans-old-back")), @@ -383,7 +383,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; let srect = fit(avail, &doc); let ppp = ctx.pixels_per_point(); - let size = ((srect.width() * ppp).round().max(1.0) as u32, (srect.height() * ppp).round().max(1.0) as u32); + // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit + // (drawn stretched to the screen). + let size = crate::textures::fit_size( + ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32), + crate::textures::max_side(&ctx), + ); if show.ended { painter.text( pos2(full.center().x, full.min.y + 40.0), @@ -439,10 +444,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ci = crate::textures::to_color_image(&img, false); let tex = match show.tex.take() { Some((_, mut h)) => { - h.set(ci, egui::TextureOptions::LINEAR); + crate::textures::replace(&ctx, &mut h, ci); h } - None => ctx.load_texture("show", ci, egui::TextureOptions::LINEAR), + None => crate::textures::upload(&ctx, "show", ci), }; show.tex = Some((key, tex.clone())); Some(tex) @@ -484,10 +489,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ci = crate::textures::to_color_image(&img, false); let frame = match tr.morph.take() { Some(mut h) => { - h.set(ci, egui::TextureOptions::LINEAR); + crate::textures::replace(&ctx, &mut h, ci); h } - None => ctx.load_texture("morph", ci, egui::TextureOptions::LINEAR), + None => crate::textures::upload(&ctx, "morph", ci), }; painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); tr.morph = Some(frame); @@ -775,16 +780,17 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no if let Some(n) = next && show.next_tex.as_ref().is_none_or(|(k, _)| *k != n) { + // A very tall slide shape would otherwise ask for a picture taller than the GPU's limit. + let next_size = crate::textures::fit_size( + (480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ctx), + ); let img = deckcraft_render::render_slide( doc, n, - &RenderOpts { - scale: 480.0 / doc.slide_size.width.max(1.0), - size: Some((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), - ..Default::default() - }, + &RenderOpts { scale: next_size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(next_size), ..Default::default() }, ); - show.next_tex = Some((n, ctx.load_texture("presenter-next", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))); + show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false)))); } let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone()); let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default(); @@ -876,7 +882,7 @@ mod tests { s.execute("transition.set", &json!({"kind": "fade", "index": 1})).unwrap(); let doc = s.active().unwrap().doc.clone(); let ctx = egui::Context::default(); - let red = ctx.load_texture("show", egui::ColorImage::filled([1, 1], Color32::RED), egui::TextureOptions::LINEAR); + let red = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED)); let mut show = Show::new(&doc, 0, false, false); show.tex = Some((Key { slide: 0, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0 }, red.clone())); assert!(show.apply(ShowAction::Slide(1), &doc, 0.0)); diff --git a/crates/ui-egui/src/texture_limit_tests.rs b/crates/ui-egui/src/texture_limit_tests.rs new file mode 100644 index 00000000..bf5c648d --- /dev/null +++ b/crates/ui-egui/src/texture_limit_tests.rs @@ -0,0 +1,53 @@ +//! Textures stay within the renderer's `max_texture_side` (2048 in the test harness, as on many +//! GPUs and software fallbacks): bigger ones panicked in egui. Slide show on a big screen, +//! thumbnails, master views and presenter view of a very tall slide. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use serde_json::json; + +use crate::{Services, SlideApp}; +use deckcraft_engine::Session; + +fn harness(session: Session, size: egui::Vec2) -> egui_kittest::Harness<'static, SlideApp> { + egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ) +} + +/// The tallest slide allowed (1 × 56): its thumbnails and presenter-view preview are tens of +/// thousands of pixels high at their usual widths. +fn tall_deck() -> Session { + let mut s = Session::with_new(); + s.execute("design.slideSize", &json!({"w": 72, "h": 4032})).unwrap(); + s.execute("slide.new", &json!({})).unwrap(); + s +} + +#[test] +fn a_slide_show_on_a_screen_wider_than_the_texture_limit() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let mut h = harness(s, egui::vec2(2600.0, 1500.0)); + h.run_steps(2); + h.state_mut().run("show.start", json!({})).unwrap(); + h.run_steps(4); + assert!(h.state().show.is_some()); +} + +#[test] +fn a_very_tall_slide_in_the_editor_views_and_presenter_view() { + let mut h = harness(tall_deck(), egui::vec2(1280.0, 800.0)); + h.run_steps(3); + for (cmd, p) in [("view.sorter", json!({})), ("view.normal", json!({})), ("view.slideMaster", json!({})), ("view.closeMaster", json!({}))] { + let _ = h.state_mut().run(cmd, p); + h.run_steps(3); + } + h.state_mut().run("show.presenter", json!({"on": true})).unwrap(); + h.state_mut().run("show.start", json!({"from": 0})).unwrap(); + h.run_steps(4); + assert!(h.state().show.is_some()); +} diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index b28f227f..df19e232 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -67,6 +67,66 @@ pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { ColorImage::new([w, h], px) } +/// The largest texture side the renderer (GPU or software fallback) takes; egui panics on +/// a bigger texture. +pub fn max_side(ctx: &egui::Context) -> u32 { + ctx.input(|i| i.max_texture_side).clamp(1, u32::MAX as usize) as u32 +} + +/// `(w, h)` scaled down, keeping its shape, so neither side exceeds `max` (and none is 0). +/// Render sizes go through this so a huge display, DPI or slide shape doesn't render a +/// picture the GPU can't hold. +pub fn fit_size((w, h): (u32, u32), max: u32) -> (u32, u32) { + let (w, h, max) = (w.max(1), h.max(1), max.max(1)); + let big = w.max(h); + if big <= max { + return (w, h); + } + let k = max as f64 / big as f64; + (((w as f64 * k).floor() as u32).clamp(1, max), ((h as f64 * k).floor() as u32).clamp(1, max)) +} + +/// `img` shrunk by whole-pixel box averaging until both sides fit `max`. +pub fn shrink_to(img: ColorImage, max: u32) -> ColorImage { + let [w, h] = img.size; + let max = max.max(1) as usize; + if w <= max && h <= max { + return img; + } + let f = w.max(h).div_ceil(max); + let (nw, nh) = (w.div_ceil(f).max(1), h.div_ceil(f).max(1)); + let mut px = Vec::with_capacity(nw * nh); + for y in 0..nh { + for x in 0..nw { + let mut acc = [0u32; 4]; + let mut n = 0u32; + for sy in y * f..((y + 1) * f).min(h) { + for sx in x * f..((x + 1) * f).min(w) { + if let Some(c) = img.pixels.get(sy * w + sx) { + for (a, v) in acc.iter_mut().zip(c.to_array()) { + *a += v as u32; + } + n += 1; + } + } + } + let n = n.max(1); + px.push(egui::Color32::from_rgba_premultiplied((acc[0] / n) as u8, (acc[1] / n) as u8, (acc[2] / n) as u8, (acc[3] / n) as u8)); + } + } + ColorImage::new([nw, nh], px) +} + +/// Upload a texture, shrunk to the renderer's limit if need be. +pub fn upload(ctx: &egui::Context, name: impl Into, img: ColorImage) -> TextureHandle { + ctx.load_texture(name, shrink_to(img, max_side(ctx)), TextureOptions::LINEAR) +} + +/// Replace a texture's picture, shrunk to the renderer's limit if need be. +pub fn replace(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) { + tex.set(shrink_to(img, max_side(ctx)), TextureOptions::LINEAR); +} + fn master_of(p: &Presentation, slide: &Slide) -> Option> { p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned() } @@ -117,7 +177,7 @@ impl Textures { _ => estimate, }; // The GPU's (or software fallback's) texture limit: a larger canvas texture panics in egui. - let max_side = ctx.input(|i| i.max_texture_side).max(1) as u32; + let max_side = max_side(ctx); let want = fit_texture(deckcraft_render::pasteboard_region(size, scale, reach, visible), visible, max_side); let key = Key { slide: Arc::as_ptr(&slide) as usize, @@ -156,10 +216,10 @@ impl Textures { let ci = to_color_image(&img, grayscale); let tex = match self.canvas.take() { Some(mut e) => { - e.tex.set(ci, TextureOptions::LINEAR); + replace(ctx, &mut e.tex, ci); e.tex } - None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR), + None => upload(ctx, "slide-canvas", ci), }; self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 }); Some((tex, region)) @@ -200,9 +260,10 @@ impl Textures { return self.thumbs.get(&key.slide).map(|e| e.tex.clone()); } *budget -= 1; - let scale = width_px as f64 / p.slide_size.width.max(1.0); - let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() }); - let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR); + let (tw, th) = fit_size((width_px, h), max_side(ctx)); + let scale = tw as f64 / p.slide_size.width.max(1.0); + let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() }); + let tex = upload(ctx, format!("thumb-{index}"), to_color_image(&img, false)); // Drop the old entry for this slide address; entries for slides that no longer exist age out. let region = key.want; self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock }); @@ -245,4 +306,26 @@ mod fit_tests { assert_eq!((f.x, f.w), (-100 + 5000 - 2048, 2048)); assert_eq!(fit_texture(r, r, 8192), r); } + + #[test] + fn render_sizes_and_pictures_shrink_to_the_limit_keeping_their_shape() { + assert_eq!(fit_size((195, 10920), 2048), (36, 2048)); + assert_eq!(fit_size((3000, 1500), 2048), (2048, 1024)); + assert_eq!(fit_size((800, 600), 2048), (800, 600)); + assert_eq!(fit_size((0, 0), 0), (1, 1)); + // A 5000 × 3 picture: half red, half blue, averaged down to 1667 × 1. + let img = ColorImage::new( + [5000, 3], + (0..3) + .flat_map(|y| (0..5000).map(move |x| (x, y))) + .map(|(x, _)| if x < 2500 { egui::Color32::RED } else { egui::Color32::BLUE }) + .collect(), + ); + let small = shrink_to(img, 2048); + assert_eq!(small.size, [1667, 1]); + assert_eq!(small.pixels[0], egui::Color32::RED); + assert_eq!(small.pixels[1666], egui::Color32::BLUE); + let ok = ColorImage::filled([10, 10], egui::Color32::WHITE); + assert_eq!(shrink_to(ok.clone(), 2048).size, ok.size); + } } diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index ebdc81d0..07ac2dae 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -270,20 +270,22 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click()); let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)); let ppp = ui.ctx().pixels_per_point(); - let px = ((tw * ppp) as u32).max(1); + let px = crate::textures::fit_size( + (((tw * ppp) as u32).max(1), ((tw * ppp) as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), + crate::textures::max_side(ui.ctx()), + ); let pic = deckcraft_render::render_layout( &doc, mi, li, &deckcraft_render::RenderOpts { - scale: px as f64 / doc.slide_size.width.max(1.0), + scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, - size: Some((px, (px as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)), + size: Some(px), ..Default::default() }, ); - let tex = - ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR); + let tex = crate::textures::upload(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false)); ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let selected = match (target, li) { (Target::Master { master }, None) => master == mi,