From 24aed7f2192a8f3fd53d8d13d8f69dcb984be90e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 1/3] App: launching opens an empty project, not the demo (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app opened the demo project whenever it wasn't given a project file, so File ▸ New Composition on the Home screen added "Comp 4" to the three demo comps. As in After Effects, a launch now starts with an empty Untitled Project. --demo opens the demo (Help ▸ Open Demo Project and the Home screen tile still do); --empty is kept. The arguments are parsed by a function now, with a test (no_arguments_start_an_empty_project). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- CONTRIBUTING.md | 2 +- README.md | 2 +- apps/effectcraft/src/main.rs | 75 +++++++++++++++++++++++++++--------- docs/agents.md | 3 +- 4 files changed, 61 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96beecf9..dfa28c9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ work apply to everyone, people and AI agents alike, and they are not negotiable. You need [Rust](https://rustup.rs/) 1.95 or newer. ```sh -cargo run -p effectcraft # the desktop app, with the demo project +cargo run -p effectcraft -- --demo # the desktop app, with the demo project cargo test --workspace cargo xtask ci # what every commit must pass ``` diff --git a/README.md b/README.md index a30f8001..f738bb1d 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ To build it yourself you need [Rust](https://rustup.rs/) 1.95 or newer. ```sh git clone https://github.com/storytold/effectcraft cd effectcraft -cargo run --release -p effectcraft # the app, with the demo project open +cargo run --release -p effectcraft # the app (add `-- --demo` to open the demo project) cargo run --release -p effectcraft-cli -- render --out intro.mp4 # render the demo headless cargo xtask web --serve 8765 # the browser build on http://127.0.0.1:8765/ (docs/web.md) ``` diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 0db5d065..aa55a6de 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -2,6 +2,9 @@ //! //! Usage: `effectcraft [--control ] [--demo|--empty|--home] [project.ecproj | media files…]` //! +//! Without a project the app starts with an empty Untitled Project, as After Effects does; +//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project). +//! //! `--control ` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server; //! see `effectcraft_ui_egui::control` for the methods. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] @@ -32,28 +35,43 @@ fn app_icon() -> egui::IconData { eframe::icon_data::from_png_bytes(png).unwrap_or_default() } +/// The command line. +#[derive(Debug, Default, PartialEq)] +struct Args { + control_port: Option, + files: Vec, + /// Open the demo project when no project is given (`--demo`). + demo: bool, + /// Show or skip the Home screen (`--home`), else the Startup preference decides. + home: Option, +} + +/// Parse the arguments after the program name; `None` for `--version`. `env_port` is +/// `EFFECTCRAFT_CONTROL_PORT`. +fn parse_args(args: impl IntoIterator, env_port: Option) -> Option { + let mut out = Args { control_port: env_port.and_then(|p| p.parse().ok()), ..Default::default() }; + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--control" => out.control_port = args.next().and_then(|p| p.parse().ok()), + "--demo" => out.demo = true, + "--empty" => out.demo = false, + "--home" => out.home = Some(true), + "--version" => return None, + _ => out.files.push(a), + } + } + Some(out) +} + fn main() -> eframe::Result { // Help ▸ Enable Logging writes through this logger; warnings feed the compatibility report. effectcraft_engine::logging::install(); effectcraft_engine::logging::install_panic_hook(); - let mut control_port: Option = std::env::var("EFFECTCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); - let mut files = Vec::new(); - let mut demo = true; - let mut home: Option = None; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--control" => control_port = args.next().and_then(|p| p.parse().ok()), - "--demo" => demo = true, - "--empty" => demo = false, - "--home" => home = Some(true), - "--version" => { - println!("effectcraft {}", env!("CARGO_PKG_VERSION")); - return Ok(()); - } - _ => files.push(a), - } - } + let Some(Args { control_port, files, demo, home }) = parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT").ok()) else { + println!("effectcraft {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + }; // The font menus list the installed fonts: read their names while the window opens. effectcraft_engine::text::fonts::scan_system_in_background(); // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. @@ -227,3 +245,24 @@ fn disable_app_nap() { std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason)); } } + +#[cfg(test)] +mod tests { + use super::{Args, parse_args}; + + fn parse(args: &[&str]) -> Option { + parse_args(args.iter().map(|a| a.to_string()), None) + } + + /// Launching without a project opens an empty project, not the demo (#204). + #[test] + fn no_arguments_start_an_empty_project() { + assert_eq!(parse(&[]), Some(Args::default())); + assert!(parse(&["--demo"]).is_some_and(|a| a.demo)); + assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo)); + let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default(); + assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()])); + assert_eq!(parse(&["--version"]), None); + assert_eq!(parse_args(Vec::new(), Some("9000".into())).map(|a| a.control_port), Some(Some(9000))); + } +} diff --git a/docs/agents.md b/docs/agents.md index b4ea4763..36c3bd62 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -491,7 +491,8 @@ not read or written. ## Seeing the UI -To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` and use MCP bridge +To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` (add `--demo` to +open the demo project; without it the app starts with an empty project) and use MCP bridge mode or the raw control channel. A good loop is: `ui_elements` to find an id, `ui_click` or `ui_drag` to act, then `screenshot {"panel":"Timeline"}` to check the result. `render_frame` shows the composition itself at any zoom. From 188cc06d2b9a6af539d1c7bbd0ada25e3d0c6828 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 2/3] App: the window's GPU device asks for what the adapter has (#198) eframe's default device request asks for wgpu's default limits (8 storage buffers per stage and more). An older GPU such as Intel HD Graphics 5500 can't meet them, so the device request failed before EffectCraft ran, and the release build on Windows quit without a word. The device now asks for the adapter's own limits (textures capped at 16384 px like the headless renderer). The compositor already checks what it needs and composites on the CPU when the device can't run it. If the window still can't open, a message dialog says why instead of the app vanishing. Test: device_limits_fit_the_adapter. Not checked on that GPU. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- apps/effectcraft/src/main.rs | 61 ++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index aa55a6de..353d6f22 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -89,9 +89,10 @@ fn main() -> eframe::Result { // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID. .with_app_id(appimage::APP_ID), event_loop_builder: agent_event_loop(control_port.is_some()), + wgpu_options: wgpu_options(), ..Default::default() }; - eframe::run_native( + let result = eframe::run_native( "EffectCraft", options, Box::new(move |cc| { @@ -188,7 +189,50 @@ fn main() -> eframe::Result { app, })) }), - ) + ); + if let Err(e) = &result { + startup_failed(e); + } + result +} + +/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px) +/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500, +/// #198) still opens the app, and the compositor then checks what it needs and falls back to +/// the CPU when the device can't run it. +fn wgpu_options() -> eframe::WgpuConfiguration { + use eframe::egui_wgpu::WgpuSetup; + let mut config = eframe::WgpuConfiguration::default(); + if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup { + setup.device_descriptor = std::sync::Arc::new(|adapter| eframe::wgpu::DeviceDescriptor { + label: Some("EffectCraft device"), + required_limits: device_limits(adapter.limits()), + ..Default::default() + }); + } + config +} + +/// The limits to request from an adapter that offers `adapter`: all of them, with textures +/// capped at 16384 px like the headless renderer's device. +fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits { + eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter } +} + +/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting +/// silently, since release builds on Windows have no console. +fn startup_failed(e: &eframe::Error) { + let message = format!( + "EffectCraft couldn't start its window: {e}\n\nUpdating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message." + ); + log::error!("{message}"); + eprintln!("effectcraft: {message}"); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("EffectCraft") + .set_description(message) + .set_buttons(rfd::MessageButtons::Ok) + .show(); } /// The desktop app: the egui app plus, on macOS, the system menu bar. @@ -254,6 +298,19 @@ mod tests { parse_args(args.iter().map(|a| a.to_string()), None) } + /// The window's device never asks for more than the adapter offers (#198). + #[test] + fn device_limits_fit_the_adapter() { + use eframe::wgpu::Limits; + for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] { + let l = super::device_limits(adapter.clone()); + assert!(l.check_limits(&adapter), "{l:?}"); + assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage); + } + let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() }; + assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384); + } + /// Launching without a project opens an empty project, not the demo (#204). #[test] fn no_arguments_start_an_empty_project() { From c357a53e5e92cae9612de6bf39d6fefacc413299 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 3/3] 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"); +}