From c357a53e5e92cae9612de6bf39d6fefacc413299 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH] Viewer: frames wider than the GPU's texture limit are shown (#201) An 11000 x 2200 comp at Full resolution renders on the CPU, and the frame went to egui as one texture over the device's 8192 px limit: the upload failed every frame (a panic in debug builds) and the window stopped drawing. Creating a comp that size at Full did the same. Frames, snapshots, the channel / exposure view, other viewers, the Layer and Footage panels and the Roto Brush overlay now go up averaged down by the smallest whole factor that fits. The full frame stays readable for pixel readouts. Tests: frames_over_the_texture_limit_are_averaged_down and full_resolution_frames_wider_than_the_texture_limit_fit (panicked before). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/ui-egui/src/frames.rs | 69 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/panels/footage_panel.rs | 2 +- crates/ui-egui/src/panels/layer_panel.rs | 2 +- crates/ui-egui/src/panels/roto_tool.rs | 2 +- crates/ui-egui/src/panels/viewer.rs | 13 ++-- crates/ui-egui/src/panels/viewer_tools.rs | 4 +- crates/ui-egui/src/panels/viewers.rs | 8 ++- crates/ui-egui/src/prefs_live.rs | 4 +- crates/ui-egui/tests/ui_viewer.rs | 23 ++++++++ 10 files changed, 117 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 9fa79594..6ff49387 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -425,6 +425,55 @@ pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorIma egui::ColorImage::new([img.width as usize, img.height as usize], px) } +/// The size `size` is shown at in a texture whose sides can't exceed `max_side`: divided by the +/// smallest whole factor that fits (frames of comps wider than the GPU's texture limit, #201). +pub fn fitted_size(size: [usize; 2], max_side: usize) -> [usize; 2] { + let k = size[0].max(size[1]).div_ceil(max_side.max(1)).max(1); + [size[0].div_ceil(k), size[1].div_ceil(k)] +} + +/// `img` as a texture the renderer accepts: unchanged when it fits in `max_side`, else averaged +/// down by a whole factor (premultiplied pixels average correctly). The viewer draws it over the +/// same area, so only detail beyond the GPU's texture limit is lost, instead of the upload +/// failing (#201). +pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage { + let [w, h] = img.size; + let [fw, fh] = fitted_size(img.size, max_side); + if [fw, fh] == [w, h] { + return img; + } + let k = w.max(h).div_ceil(max_side.max(1)).max(1); + let px: Vec = (0..fw * fh) + .into_par_iter() + .map(|i| { + let (x0, y0) = ((i % fw) * k, (i / fw) * k); + let (mut sum, mut n) = ([0u32; 4], 0u32); + for y in y0..(y0 + k).min(h) { + for c in img.pixels.get(y * w + x0..y * w + (x0 + k).min(w)).unwrap_or_default() { + for (s, v) in sum.iter_mut().zip(c.to_array()) { + *s += v as u32; + } + n += 1; + } + } + let n = n.max(1); + let [r, g, b, a] = sum.map(|s| ((s + n / 2) / n) as u8); + egui::Color32::from_rgba_premultiplied(r, g, b, a) + }) + .collect(); + egui::ColorImage::new([fw, fh], px) +} + +/// The largest texture side the renderer accepts. +pub fn max_texture_side(ctx: &egui::Context) -> usize { + ctx.input(|i| i.max_texture_side) +} + +/// `ctx.load_texture` for frame-sized images: fitted to the renderer's texture limit first. +pub fn load_fitted(ctx: &egui::Context, name: impl Into, img: egui::ColorImage, opts: egui::TextureOptions) -> egui::TextureHandle { + ctx.load_texture(name, fit_texture(img, max_texture_side(ctx)), opts) +} + impl Frames { /// Retire only GPU preview state; documents, CPU frames and running job leases survive. /// Publication checks the same latch while holding the cache lock, so a late GPU frame @@ -1015,6 +1064,26 @@ impl Queue { mod tests { use super::*; + /// Frames larger than the texture limit are averaged down by a whole factor (#201: an + /// 11000×2200 comp at Full resolution against an 8192 limit). + #[test] + fn frames_over_the_texture_limit_are_averaged_down() { + assert_eq!(fitted_size([11000, 2200], 8192), [5500, 1100]); + assert_eq!(fitted_size([8192, 100], 8192), [8192, 100]); + assert_eq!(fitted_size([30000, 30000], 16384), [15000, 15000]); + assert_eq!(fitted_size([0, 0], 0), [0, 0]); + // 5×2 (three columns of white, then black) into 2 px: 3×2 blocks (the last one 2×2). + let w = egui::Color32::WHITE; + let b = egui::Color32::from_rgba_premultiplied(0, 0, 0, 255); + let img = egui::ColorImage::new([5, 2], vec![w, w, w, b, b, w, w, w, b, b]); + let out = fit_texture(img.clone(), 2); + assert_eq!(out.size, [2, 1]); + assert_eq!(out.pixels, vec![w, b]); + assert_eq!(fit_texture(img.clone(), 5).pixels, img.pixels); + let mixed = fit_texture(egui::ColorImage::new([2, 1], vec![w, b]), 1); + assert_eq!(mixed.pixels, vec![egui::Color32::from_rgba_premultiplied(128, 128, 128, 255)]); + } + #[test] fn retirement_reaches_existing_workers_and_keeps_cpu_frames_and_completion_live() { let frames = Frames::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 151aae21..358cc0f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -576,6 +576,11 @@ impl EffectcraftApp { self.gpu.as_ref().map(effectcraft_engine::render::Accelerator::name) } + /// The size of the viewer frame's texture (CPU frames; fitted to the GPU's texture limit). + pub fn viewer_texture_size(&self) -> Option<[usize; 2]> { + self.viewer_tex.as_ref().map(|(t, _)| t.size()) + } + /// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the /// first time something asks for it. /// diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index cfc69825..70ba94b5 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -58,7 +58,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some((k, tex, size)) if k == key => Some((tex, size)), _ => app.session.footage.frame(view.item, f, view.time).map(|img| { let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); - let tex = ctx.load_texture("footage-panel", ci, egui::TextureOptions::LINEAR); + let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height]))); (tex, [img.width, img.height]) }), diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 0e45bf32..e7a689f9 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -147,7 +147,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let x0 = -buf.offset[0] / buf.scale; let y0 = -buf.offset[1] / buf.scale; let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale]; - let tex = ctx.load_texture("layer-panel", img, egui::TextureOptions::LINEAR); + let tex = crate::frames::load_fitted(&ctx, "layer-panel", img, egui::TextureOptions::LINEAR); let c = Tex { key, tex, rect }; ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone())); c diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 87c00478..9eacaad9 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -122,7 +122,7 @@ pub fn draw_overlay( let x0 = -inp.offset[0] / inp.scale; let y0 = -inp.offset[1] / inp.scale; let rect = [x0, y0, x0 + w as f64 / inp.scale, y0 + h as f64 / inp.scale]; - let tex = ctx.load_texture("roto-overlay", img, egui::TextureOptions::NEAREST); + let tex = crate::frames::load_fitted(ctx, "roto-overlay", img, egui::TextureOptions::NEAREST); let c = Overlay { key, tex, rect }; ctx.data_mut(|d| d.insert_temp(id, c.clone())); Some(c) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 83174218..95a4dd0b 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -397,7 +397,7 @@ fn aux_views( _ => { let opts = effectcraft_engine::render::RenderOpts { scale, view: comp.has_3d().then_some(cam), draft: true, ..Default::default() }; let img = app.session.render(cid, t, opts); - let tex = ctx.load_texture(format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR); + let tex = crate::frames::load_fitted(&ctx, format!("viewer-aux-{i}"), crate::frames::to_color_image(&img), egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } @@ -459,7 +459,7 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo dc.apply(&mut px); ci = egui::ColorImage::new(ci.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()); } - let tex = ctx.load_texture("viewer-locked", ci, egui::TextureOptions::LINEAR); + let tex = crate::frames::load_fitted(&ctx, "viewer-locked", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } @@ -1912,12 +1912,15 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: let opts = zoom_texture_options(smooth); match img { FrameImage::Cpu(img) => { + // Frames wider or taller than the GPU's texture limit go up averaged down (#201). + let max = crate::frames::max_texture_side(ctx); + let fitted = crate::frames::fit_texture((*img).clone(), max); match &mut app.viewer_tex { - Some((tex, k)) if tex.size() == img.size => { - tex.set((*img).clone(), opts); + Some((tex, k)) if tex.size() == fitted.size => { + tex.set(fitted, opts); *k = key; } - _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", (*img).clone(), opts), key)), + _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key)), } app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k)); app.viewer_image = Some(img); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 0d37ad13..ca0bd791 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -382,7 +382,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: _ => { let img = crate::frames::to_color_image(&s.image); let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) }; - let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR); + let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } @@ -432,7 +432,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { Some((k, tex)) if k == key => tex, _ => { - let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts); + let tex = crate::frames::load_fitted(ctx, "viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 9c3add1c..be01f5b8 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -131,13 +131,15 @@ fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: Fram } let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); let tex = match (img, app.passive_tex.remove(&id)) { - (FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h)))) if h.size() == img.size => { - h.set((*img).clone(), opts); + (FrameImage::Cpu(img), Some((_, PassiveTexture::Cpu(mut h)))) + if h.size() == crate::frames::fitted_size(img.size, crate::frames::max_texture_side(ctx)) => + { + h.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts); PassiveTexture::Cpu(h) } (FrameImage::Cpu(img), old) => { free(app, old.map(|(_, t)| t)); - PassiveTexture::Cpu(ctx.load_texture(format!("viewer-{id}"), (*img).clone(), opts)) + PassiveTexture::Cpu(crate::frames::load_fitted(ctx, format!("viewer-{id}"), (*img).clone(), opts)) } (FrameImage::Gpu(f), old) => { let Some(rs) = &app.wgpu else { return }; diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index 14e7b7cc..41a4932b 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -108,10 +108,10 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { let opts = crate::panels::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); let t = match prev { Some((_, mut t)) => { - t.set((*img).clone(), opts); + t.set(crate::frames::fit_texture((*img).clone(), crate::frames::max_texture_side(ctx)), opts); t } - None => ctx.load_texture("video-preview", (*img).clone(), opts), + None => crate::frames::load_fitted(ctx, "video-preview", (*img).clone(), opts), }; ctx.data_mut(|d| d.insert_temp(tex_id, (k, t.clone()))); Some(t) diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index bb610223..52033fe3 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1019,3 +1019,26 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() { drag_path(&mut h, &path, egui::Modifiers::SHIFT); assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h)); } + +/// A comp wider than the GPU's texture limit at Full resolution shows its frame (averaged down +/// into a texture the renderer accepts) instead of failing the upload (#201: 11000×2200). +#[test] +fn full_resolution_frames_wider_than_the_texture_limit_fit() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Wide", "width": 2400, "height": 400, "duration": 1})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + // (egui's font atlas needs 1024.) + h.input_mut().max_texture_side = Some(1024); + h.state_mut().ui.viewer.res = effectcraft_ui_egui::state::Resolution::Full; + let full = |h: &mut Harness<'_, EffectcraftApp>| h.state_mut().viewer_pixels().is_some_and(|px| px.size == [2400, 400]); + for _ in 0..400 { + h.step(); + if full(&mut h) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(full(&mut h), "the full-size frame is shown (and stays readable): {:?}", h.state().ui.viewer.res); + assert_eq!(h.state().viewer_texture_size(), Some([800, 134]), "2400×400 averaged by 3"); +}