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..353d6f22 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. @@ -71,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| { @@ -170,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. @@ -227,3 +289,37 @@ 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) + } + + /// 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() { + 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/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"); +} 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.