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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
This commit is contained in:
Claude 2026-10-07 22:13:19 +00:00
parent 188cc06d2b
commit c357a53e5e
No known key found for this signature in database
10 changed files with 117 additions and 15 deletions

View File

@ -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<egui::Color32> = (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<String>, 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();

View File

@ -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.
///

View File

@ -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])
}),

View File

@ -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

View File

@ -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)

View File

@ -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);

View File

@ -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
}

View File

@ -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 };

View File

@ -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)

View File

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