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"); +}