From 3af4649429de70f4b9b0b66bbee7a6129c457e52 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 15:37:26 -0400 Subject: [PATCH 01/14] Rename PrintCraft to PdfCraft (storytold/pdfcraft) (#207) Co-authored-by: Claude Opus 5.5 --- .github/workflows/windows-arm64.yml | 2 +- CLAUDE.md | 2 +- README.md | 2 +- crates/engine/src/commands/help.rs | 2 +- crates/engine/src/links.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 4803d27b..8f1aac5c 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -1,7 +1,7 @@ # Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 # runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed # binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left -# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.) +# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) name: Windows ARM64 on: diff --git a/CLAUDE.md b/CLAUDE.md index afa08d3d..da31c48f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # EffectCraft — instructions for agents -EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../printcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions. +EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual effects compositor targeting Adobe After Effects parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../photocraft` (Photoshop), `../pdfcraft` (Acrobat), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom) and `../designcraft` (InDesign), with the same conventions. ## Start every session here `plan/` is maintainer-local (gitignored). Public equivalents: [`ROADMAP.md`](ROADMAP.md), [`docs/`](docs/). diff --git a/README.md b/README.md index f4704e53..a30f8001 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ stand on its own. | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | -| | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) | +| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | **Motion graphics and visual effects · you are here** | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | diff --git a/crates/engine/src/commands/help.rs b/crates/engine/src/commands/help.rs index 7a22f0db..c6f315f3 100644 --- a/crates/engine/src/commands/help.rs +++ b/crates/engine/src/commands/help.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Other ArtCraft Apps", [], None, - "{app: photocraft|vectorcraft|filmcraft|lightcraft|printcraft|designcraft, kind?: page|github}", + "{app: photocraft|vectorcraft|filmcraft|lightcraft|pdfcraft|designcraft, kind?: page|github}", always, sibling ), diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index df140ea4..0a0ad482 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -15,7 +15,7 @@ pub const SIBLINGS: &[(&str, &str)] = &[ ("VectorCraft", "vectorcraft"), ("FilmCraft", "filmcraft"), ("LightCraft", "lightcraft"), - ("PrintCraft", "printcraft"), + ("PdfCraft", "pdfcraft"), ("DesignCraft", "designcraft"), ]; From e87e1b763ecbf2a6b61fc19a68ab06be8d0e0732 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:18 +0000 Subject: [PATCH 02/14] Effects: Audio Spectrum and Audio Waveform follow time in the viewer (#209) Both visualisers read their Audio Layer's samples around the frame time, but they weren't in TIME_DEPENDENT, so the viewer's layer cache gave the layer one key for every frame and showed the first frame's spectrum for the whole preview. Export doesn't use that cache, which is why it rendered correctly. Test: audio_spectrum_follows_time_through_the_layer_cache renders a tone that starts at 0.5 s through a shared layer cache after t = 0 and compares it with an uncached render (it showed the stale baseline). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/effects/src/lib.rs | 3 +++ crates/render/src/tests_audio_fx.rs | 39 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index fd28626f..5f832c20 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -1107,6 +1107,9 @@ pub const TIME_DEPENDENT: &[&str] = &[ "ec.obsolete.lightning", "ec.text.timecode", "ec.text.numbers", + // The audio visualisers read the Audio Layer's samples around the frame time. + "ec.generate.audiospectrum", + "ec.generate.audiowaveform", // Path Text's jitter changes every frame. "ec.obsolete.pathtext", // Time effects read neighbouring frames of the layer. diff --git a/crates/render/src/tests_audio_fx.rs b/crates/render/src/tests_audio_fx.rs index 4cb4e5e7..aced8208 100644 --- a/crates/render/src/tests_audio_fx.rs +++ b/crates/render/src/tests_audio_fx.rs @@ -214,3 +214,42 @@ fn peak_summary_bins() { assert_eq!(peaks.len(), 200); assert!((peaks[150][1] - 1.51).abs() < 0.01, "{:?}", peaks[150]); } + +/// Audio Spectrum reads its Audio Layer's samples at the frame time, so the layer cache keys it +/// by time: a cached frame after the sound starts matches an uncached render (#209). +#[test] +fn audio_spectrum_follows_time_through_the_layer_cache() { + fn late_tone(t: f64) -> (f32, f32) { + let v = if t < 0.5 { 0.0 } else { (2.0 * std::f64::consts::PI * 1000.0 * t).sin() as f32 }; + (v, v) + } + let mut p = Project::default(); + let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); + let audio = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(audio_footage())); + let a = build::layer(&mut p, &comp, "a", LayerSource::Footage { item: audio }, (64, 64), None); + let sid = p.add_item("S", effectcraft_color::Label::Red, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 64, height: 64, pixel_aspect: 1.0 })); + let mut s = build::layer(&mut p, &comp, "s", LayerSource::Solid { item: sid }, (64, 64), None); + let spec = effectcraft_effects::find("ec.generate.audiospectrum").unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [64.0, 64.0]); + p.next_id = next; + g.prop_mut("audioLayer").unwrap().value = Value::Layer(Some(a.id.0)); + s.props.sub_mut("effects").unwrap().children.push(g.into()); + let mut comp = comp; + comp.layers = vec![s, a]; + let cid = p.add_item("C", effectcraft_color::Label::Sandstone, None, ItemKind::Comp(comp.into())); + + let src = Signal(late_tone); + let render = |t: f64, cache: Option<&crate::LayerCache>| { + let mut r = crate::Renderer::new(&p, &src, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(cid, Tick::from_seconds_f64(t)) + }; + let lit = |img: &Image| img.data.iter().filter(|px| px[0] + px[1] + px[2] > 0.1).count(); + let cache = crate::LayerCache::default(); + let silent = render(0.0, Some(&cache)); + let cached = render(0.8, Some(&cache)); + let fresh = render(0.8, None); + assert!(lit(&fresh) > lit(&silent), "the tone draws more than the baseline: {} vs {}", lit(&fresh), lit(&silent)); + assert_eq!(cached.data, fresh.data, "cached {} px, uncached {} px", lit(&cached), lit(&fresh)); +} From 24aed7f2192a8f3fd53d8d13d8f69dcb984be90e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 03/14] 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 b8b13b1cec63119ed5324020f0639302316bb277 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 04/14] Timeline: shape layers get the Contents "Add:" menu; Classic 3D shows Change Renderer (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trim Paths, Merge Paths, Repeater and the other path operations existed (layer.addShapeItem) but no menu added them to a shape layer. Like After Effects, a shape layer's Contents row now has "Add:" with Group, Rectangle, Ellipse, Polystar, Path, Fill, Stroke, Gradient Fill, Gradient Stroke and the path operations; with a shape group selected they go into it. layer.addShapeItem gains the empty Path and Gradient Stroke kinds. A 3D text or shape layer in a Classic 3D comp now shows Geometry Options with "Change Renderer…", which opens Composition Settings on its 3D Renderer tab (Advanced 3D comps show the extrusion options). Popup menus are as tall as the window allows and open upward near its bottom: the 20-entry Add menu was cut at 420 px, hiding Trim Paths. Tests: shape_contents_add_menu_adds_path_operations, classic_3d_shape_layers_offer_change_renderer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/engine/src/commands/layer.rs | 4 +- crates/ui-egui/src/panels/timeline.rs | 118 ++++++++++++++++++++- crates/ui-egui/src/widgets.rs | 12 ++- crates/ui-egui/tests/ui_timeline_shapes.rs | 80 ++++++++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/ui_timeline_shapes.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 2fe07616..5881b329 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1075,7 +1075,9 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result { "polygon" => build::shape_star(&mut ids, false, 5.0, [0.0, 0.0], 100.0, 0.0), "fill" => build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]), "stroke" => build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 2.0), + "path" => build::shape_path(&mut ids, Default::default()), "gfill" | "gradientFill" => build::shape_gradient_fill(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default()), + "gstroke" | "gradientStroke" => build::shape_gradient_stroke(&mut ids, false, [-100.0, 0.0], [100.0, 0.0], Default::default(), 2.0), "trim" | "trimPaths" => build::shape_trim(&mut ids, 0.0, 100.0, 0.0), "repeater" => build::shape_repeater(&mut ids, 3.0, [100.0, 0.0]), k => build::shape_simple_op(&mut ids, k).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{k}`")))?, @@ -1404,7 +1406,7 @@ pub fn specs() -> Vec { "Add (Shape)", [], None, - "{layer?, kind: group|rect|ellipse|star|polygon|fill|stroke|gfill|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}", + "{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}", has_layers, add_shape_item ), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8af70d55..89322f71 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -47,6 +47,16 @@ enum RowKind { /// Synthetic group uid for a layer's Audio > Waveform twirl (never a real property uid). const WAVE_BIT: u64 = 1 << 60; +/// Synthetic group uid for the Geometry Options row of a 3D text or shape layer in a Classic 3D +/// comp, which only offers "Change Renderer…" (never a real property uid). +const GEOM_BIT: u64 = 1 << 59; + +/// Classic 3D comps don't extrude: a 3D text or shape layer's Geometry Options row offers to +/// change the renderer instead, as in After Effects. +fn classic_geometry_row(comp: &Comp, l: &Layer) -> bool { + comp.renderer == effectcraft_engine::project::Renderer::Classic3D && l.is_3d() && matches!(l.source, LayerSource::Text | LayerSource::Shape) +} + /// Time navigator's visible-span bar and the work area bar. const NAV_BAR: Color32 = Color32::from_rgb(0x55, 0x55, 0x55); const WORK_AREA_BAR: Color32 = Color32::from_rgb(0x5c, 0x5c, 0x5c); @@ -597,8 +607,11 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { reveal_rows(app, l, kinds, &mut rows); continue; } + let classic_geometry = classic_geometry_row(comp, l); for c in &l.props.children { match c { + // The Change Renderer row (after Transform) stands in for the extrusion options. + Node::Group(g) if classic_geometry && g.match_id == "geometryOptions" => {} Node::Group(g) if group_visible(g, l) => { let open = tl.open_groups.contains(&g.uid); rows.push(Row { @@ -631,6 +644,20 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { } } } + if classic_geometry && g.match_id == "transform" { + rows.push(Row { + layer: l.id, + depth: 1, + kind: RowKind::Group { + uid: GEOM_BIT | l.id.0, + name: "Geometry Options".into(), + open: false, + has_children: false, + fx: None, + eye: None, + }, + }); + } } Node::Prop(p) if prop_visible(p, l) => rows.push(Row { layer: l.id, depth: 1, kind: RowKind::Prop { uid: p.uid } }), _ => {} @@ -1704,6 +1731,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text); text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); + shape_add_popup(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); + if *uid == GEOM_BIT | layer.id.0 { + let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, "Change Renderer…", Tokens::ui(11.5), t.accent); + let resp = + ui.interact(lr, egui::Id::new(("tl-change-renderer", layer.id.0)), Sense::click()).on_hover_text("Composition Settings ▸ 3D Renderer"); + if resp.hovered() { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); + } + app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…"); + if resp.clicked() { + ui_actions.push(UiAct::ChangeRenderer); + } + } dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions); // Mask mode + inverted inline. if let Some(g) = layer.props.find_group(*uid) @@ -1727,7 +1767,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y)); let gr = ui.interact(gr_rect, egui::Id::new(("grow", uid)), Sense::click()); app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name); - if gr.clicked() && uid & WAVE_BIT == 0 { + if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); } if gr.double_clicked() { @@ -2444,6 +2484,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } UiAct::SetTime(tt) => app.session.set_time(tt), UiAct::EndMerge => app.session.history.merge_key = None, + UiAct::ChangeRenderer => { + if super::dialogs::open_comp_settings(app).is_ok() { + app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer; + } + } } } if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") { @@ -2561,6 +2606,8 @@ enum UiAct { ToggleGroup(u64), SetTime(Tick), EndMerge, + /// Composition Settings on its 3D Renderer tab. + ChangeRenderer, } fn walk_groups(g: &PropGroup, out: &mut Vec) { @@ -2601,6 +2648,75 @@ fn dash_buttons( } } +/// The shape items the Contents row's "Add:" menu offers, as `layer.addShapeItem` kinds ("-" is +/// a separator), in After Effects' order. +const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[ + ("group", "Group (empty)"), + ("-", ""), + ("rect", "Rectangle"), + ("ellipse", "Ellipse"), + ("star", "Polystar"), + ("path", "Path"), + ("-", ""), + ("fill", "Fill"), + ("stroke", "Stroke"), + ("gfill", "Gradient Fill"), + ("gstroke", "Gradient Stroke"), + ("-", ""), + ("merge", "Merge Paths"), + ("offset", "Offset Paths"), + ("pucker", "Pucker & Bloat"), + ("repeater", "Repeater"), + ("round", "Round Corners"), + ("trim", "Trim Paths"), + ("twist", "Twist"), + ("wiggle", "Wiggle Paths"), + ("zigzag", "Zig Zag"), +]; + +/// A shape layer's Contents "Add:" pop-up menu: adds a shape, paint or path operation to the +/// selected shape group, else to the layer's contents. +#[allow(clippy::too_many_arguments)] +fn shape_add_popup( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + layer: &Layer, + uid: u64, + x: f32, + cy: f32, + actions: &mut Vec<(String, serde_json::Value)>, +) { + if !matches!(layer.source, LayerSource::Shape) || layer.props.sub("contents").is_none_or(|c| c.uid != uid) { + return; + } + let t = app.tokens; + p.text(pos2(x + 6.0, cy), Align2::LEFT_CENTER, "Add:", Tokens::ui(11.5), t.text_dim); + let br = Rect::from_center_size(pos2(x + 40.0, cy), vec2(16.0, 16.0)); + let resp = ui.interact(br, egui::Id::new(("tl-shapeadd", uid)), Sense::click()).on_hover_text("Add a shape, fill, stroke or path operation"); + icons::paint(p, br.shrink(3.0), Icon::ChevronRight, if resp.hovered() { t.text } else { t.text_dim }); + app.auto.add(&format!("timeline.group.{uid}.add"), br, "Add:"); + let pop = egui::Id::new(("tl-shapeadd-pop", uid)); + if resp.clicked() { + widgets::open_popup(ui, pop); + } + let opts: Vec = SHAPE_ADD_ITEMS.iter().map(|(k, l)| if *k == "-" { "-".to_string() } else { l.to_string() }).collect(); + if let Some(i) = widgets::popup_menu(ui, pop, br.left_bottom(), &opts, None) + && let Some((kind, _)) = SHAPE_ADD_ITEMS.get(i).filter(|(k, _)| *k != "-") + { + let mut params = json!({"layer": layer.id.0, "kind": kind}); + // Into the selected shape group, as After Effects does. + let group = app.session.state.selected_props.iter().filter(|(l, _)| *l == layer.id).find_map(|(_, u)| { + let g = layer.props.find_group(*u)?; + (g.match_id == "group" && g.sub("contents").is_some()).then_some(g.uid) + }); + if let Some(g) = group { + params["group"] = json!(g); + } + actions.push(("layer.addShapeItem".into(), params)); + } +} + /// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus). #[allow(clippy::too_many_arguments)] fn text_anim_popups( diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 703375e8..c737953f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -283,10 +283,20 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String] if !open { return None; } + // As tall as the window allows (a shape layer's Add menu has 20 entries), scrolling beyond, + // and moved up when it would run past the bottom of the window (the Timeline's menus). + let screen = ui.ctx().content_rect(); + let max_h = (screen.height() - 24.0).max(120.0); + let sp = ui.spacing(); + let seps = options.iter().filter(|o| *o == "-").count() as f32; + let rows = options.len() as f32 - seps; + let est = (rows * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0); + let pos = egui::pos2(pos.x, pos.y.min(screen.bottom() - est).max(screen.top())); let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(160.0); - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + // (An area's content is laid out in last frame's size: ask for the estimate.) + egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| { for (i, o) in options.iter().enumerate() { if o == "-" { ui.separator(); diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs new file mode 100644 index 00000000..dc62062d --- /dev/null +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -0,0 +1,80 @@ +//! Timeline rows for shape layers: the Contents "Add:" menu and the Classic 3D "Change +//! Renderer…" row (#206). + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +fn harness(s: Session) -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + s +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + click_at(h, p); +} + +fn open_layer(h: &mut Harness<'_, EffectcraftApp>, id: u64) { + h.state_mut().ui.timeline.open_layers.insert(id); + h.run_steps(2); +} + +#[test] +fn shape_contents_add_menu_adds_path_operations() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "ellipse"})).unwrap()["layer"].as_u64().unwrap(); + let contents = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().uid; + let mut h = harness(s); + open_layer(&mut h, l); + click(&mut h, &format!("timeline.group.{contents}.add")); + let entry = h.get_by_label("Trim Paths").rect(); + assert!(entry.max.y <= 1000.0, "the whole menu fits in the window: {entry:?}"); + click_at(&mut h, entry.center()); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let items: Vec<&str> = layer.props.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect(); + assert_eq!(items, ["group", "trim"]); +} + +#[test] +fn classic_3d_shape_layers_offer_change_renderer() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + let mut h = harness(s); + open_layer(&mut h, l); + click(&mut h, &format!("timeline.layer.{l}.changeRenderer")); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert!(h.state().auto.find("dialog.comp.renderer").is_some(), "on the 3D Renderer tab"); + // Advanced 3D shows the extrusion options instead. + h.state_mut().dialog = None; + h.state_mut().session.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap(); + h.run_steps(3); + assert!(h.state().auto.find(&format!("timeline.layer.{l}.changeRenderer")).is_none()); +} + From 188cc06d2b9a6af539d1c7bbd0ada25e3d0c6828 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 05/14] 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 b26f55a77512df80213de08910e25fa3d2001eef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 06/14] Shapes and timeline: toolbar Fill / Stroke edit selected shape layers; Alt+Shift+P reveals; click below layers deselects (#205) - With a shape layer selected, the toolbar shows its Fill, Stroke and Stroke Width (with any tool) and editing them changes the layer's fills and strokes, as in After Effects (new shape.fillStroke, one undo step per picker or drag). Shapes drawn without a stroke get one when a stroke colour or width is set, so changing the stroke now works. - Alt+Shift+P (A, S, R, T) reveals the property it keyed on each layer. - A click in the time graph below the last layer deselects the layers (it only deselected keyframes), so a shape tool draws a new shape layer instead of a mask on the selected image. Tests: toolbar_fill_and_stroke_paint_selected_shape_layers, toolbar_stroke_edits_the_selected_shape_layer, add_position_key_reveals_position, clicking_below_the_layers_deselects_them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/engine/src/commands/mod.rs | 2 +- crates/engine/src/commands/shape_stroke.rs | 116 ++++++++++++++++++++- crates/engine/src/tests.rs | 34 ++++++ crates/ui-egui/src/header.rs | 50 ++++++++- crates/ui-egui/src/menus.rs | 33 +++++- crates/ui-egui/src/panels/timeline.rs | 7 +- crates/ui-egui/tests/ui_timeline_shapes.rs | 50 ++++++++- 7 files changed, 280 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 9594b8a4..7c8e9613 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -54,7 +54,7 @@ pub mod roto_cmds; mod scene_detect; pub mod scripts; mod settings; -mod shape_stroke; +pub mod shape_stroke; mod stubs; mod styles; mod text_anim; diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs index 29b945a7..51ed0724 100644 --- a/crates/engine/src/commands/shape_stroke.rs +++ b/crates/engine/src/commands/shape_stroke.rs @@ -1,14 +1,16 @@ //! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and //! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the //! commands here set several of them in one undo step and add the groups to strokes saved before -//! they existed. +//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers. use effectcraft_keyframe::Value as KV; use effectcraft_project::build::{self, EXTRA_DASHES, Ids}; -use effectcraft_project::{ItemId, LayerId, PropGroup, Uid}; +use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid}; +use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p}; +use super::layer::color_p; +use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p}; use crate::{EngineError, Result, Session, cmd}; fn is_stroke(g: &PropGroup) -> bool { @@ -195,8 +197,116 @@ fn wave(s: &mut Session, p: &Value) -> Result { ) } +/// Path items a Fill or Stroke paints in their group. +const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"]; + +/// Shape groups nest; deeper contents are left alone (never-crash recursion bound). +const MAX_DEPTH: usize = 64; + +/// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time +/// `lt` (what the toolbar shows for a selected shape layer). +pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4]>, Option) { + let (mut fill, mut stroke, mut width) = (None, None, None); + if let Some(c) = layer.props.sub("contents") { + walk_groups(c, &mut |g| { + let at = |id: &str| g.get(id).map(|p| p.value_at(lt)); + let rgba = |v: KV| v.as_color().map(f64::from); + match g.match_id.as_str() { + "fill" if fill.is_none() => fill = at("color").map(rgba), + "stroke" if stroke.is_none() => { + stroke = at("color").map(rgba); + width = at("width").map(|v| v.as_f64()); + } + _ => {} + } + }); + } + (fill, stroke, width) +} + +/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and +/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before +/// its fills, as new shapes have it). +#[allow(clippy::too_many_arguments)] +fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option, add_stroke: bool, depth: usize) { + if depth > MAX_DEPTH { + return; + } + if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) { + let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len()); + c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into()); + } + for n in &mut c.children { + let Node::Group(g) = n else { continue }; + let set = |g: &mut PropGroup, id: &str, v: Option| { + if let (Some(v), Some(p)) = (v, g.get_mut(id)) { + p.set_value_at(lt, v); + } + }; + match g.match_id.as_str() { + "fill" => set(g, "color", fill.map(KV::Color)), + "stroke" => { + set(g, "color", stroke.map(KV::Color)); + set(g, "width", width.map(KV::Scalar)); + } + "gstroke" => set(g, "width", width.map(KV::Scalar)), + "group" => { + if let Some(sub) = g.sub_mut("contents") { + paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1); + } + } + _ => {} + } + } +} + +/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they +/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's, +/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that +/// have no stroke one. Without values, reports the first layer's paint. +fn fill_stroke(s: &mut Session, p: &Value) -> Result { + let c = "shape.fillStroke"; + let (cid, lids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let shapes: Vec = lids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape))).collect(); + if shapes.is_empty() { + return Err(bad(c, "select a shape layer")); + } + let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]); + let (fill, stroke) = (rgba("fill"), rgba("stroke")); + let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0)); + if fill.is_some() || stroke.is_some() || width.is_some() { + let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0); + let t = s.time(); + s.edit("Fill and Stroke", merge_p(p), |proj, _| { + let mut next = proj.next_id; + for lid in &shapes { + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + if let Some(contents) = l.props.sub_mut("contents") { + paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0); + } + } + proj.next_id = next; + Ok(()) + })?; + } + let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?; + let (fill, stroke, width) = first_paint(l, l.layer_time(s.time())); + Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width})) +} + pub fn specs() -> Vec { vec![ + cmd!( + "shape.fillStroke", + "Fill and Stroke", + [], + None, + "{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)", + has_layers, + fill_stroke + ), cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add), cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove), cmd!( diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f505b2a5..8ae9fcd4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -183,6 +183,40 @@ fn text_shape_mask_matte_parent() { assert_eq!(info["layers"].as_array().unwrap().len(), 2); } +/// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn +/// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include +/// an empty Path and a Gradient Stroke that render. +#[test] +fn toolbar_fill_and_stroke_paint_selected_shape_layers() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let paint = |s: &mut Session| s.execute("shape.fillStroke", json!({})).unwrap(); + assert_eq!(paint(&mut s)["stroke"], serde_json::Value::Null, "drawn without a stroke"); + s.execute("shape.fillStroke", json!({"stroke": "#00ff00", "strokeWidth": 6})).unwrap(); + let p = paint(&mut s); + assert_eq!((p["stroke"][1].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(6.0)), "{p}"); + s.execute("shape.fillStroke", json!({"fill": "#0000ff", "strokeWidth": 3})).unwrap(); + let p = paint(&mut s); + assert_eq!((p["fill"][2].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(3.0)), "{p}"); + // One stroke, before the fill (paths, stroke, fill), and each change is one undo step. + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone(); + let group = l.props.group("contents/group").unwrap(); + let order: Vec<&str> = group.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect(); + assert_eq!(order, ["rect", "stroke", "fill"]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(paint(&mut s)["strokeWidth"].as_f64(), Some(6.0)); + // Only shape layers. + s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap(); + assert!(s.execute("shape.fillStroke", json!({"fill": "#ffffff"})).is_err()); + for kind in ["path", "gstroke"] { + let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": kind})).unwrap(); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with("contents/")), "{r}"); + } + let cid = s.active_comp_id().unwrap(); + let _ = s.render(cid, s.time(), Default::default()); +} + #[test] fn save_and_open_roundtrip() { let mut s = demo(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 214d2712..2f4c98b0 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -9,6 +9,7 @@ use crate::state::Tool; use crate::theme::Tokens; use crate::widgets; use crate::{Dialog, EffectcraftApp}; +use effectcraft_engine::project::{LayerId, LayerSource}; pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; @@ -91,7 +92,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Tool options. x += 10.0; - if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen { + // Selected shape layers: the options show their paint and edit it (as in After Effects). + let shape_layers: Vec = app + .session + .active_comp() + .map(|c| { + app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect() + }) + .unwrap_or_default(); + if let Some(l) = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))) { + let (fill, stroke, width) = effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time())); + let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32]; + if let Some(c) = fill { + app.ui.fill_color = rgb(c); + } + if let Some(c) = stroke { + app.ui.stroke_color = rgb(c); + } + app.ui.stroke_width = width.unwrap_or(0.0) as f32; + } + // One undo step per picker session or width drag: end the merge once both are done. + let picking = ["tool-fill-pop", "tool-stroke-pop"].iter().any(|k| ui.data(|d| d.get_temp::(egui::Id::new(*k).with("open")).unwrap_or(false))); + if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) { + app.session.history.merge_key = None; + } + if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen || !shape_layers.is_empty() { + let paint = |app: &mut EffectcraftApp, key: &str, v: serde_json::Value| { + if shape_layers.is_empty() { + return; + } + let merge = format!("tool-{key}"); + if let Err(e) = app.session.execute("shape.fillStroke", serde_json::json!({"layers": shape_layers, key: v, "merge": merge})) { + app.ui.status = e.to_string(); + } + }; p.text(pos2(x, cy), Align2::LEFT_CENTER, "Fill:", Tokens::ui(12.0), t.text_dim); x += 28.0; let fr = Rect::from_center_size(pos2(x + 10.0, cy), vec2(20.0, 16.0)); @@ -99,7 +133,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if widgets::swatch(ui, fr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-fill"), &t).clicked() { widgets::open_popup(ui, egui::Id::new("tool-fill-pop")); } - color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color); + if color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color) { + let c = app.ui.fill_color; + paint(app, "fill", serde_json::json!(c)); + } + app.auto.add("header.fill", fr, "Fill Color"); x += 32.0; p.text(pos2(x, cy), Align2::LEFT_CENTER, "Stroke:", Tokens::ui(12.0), t.text_dim); x += 44.0; @@ -108,13 +146,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if widgets::swatch(ui, sr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-stroke"), &t).clicked() { widgets::open_popup(ui, egui::Id::new("tool-stroke-pop")); } - color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color); + if color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color) { + let c = app.ui.stroke_color; + paint(app, "stroke", serde_json::json!(c)); + } + app.auto.add("header.stroke", sr, "Stroke Color"); x += 26.0; let (r, v, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new("tool-stroke-w"), app.ui.stroke_width as f64, 0.2, (0.0, 1000.0), 0, " px", &t); if let Some(v) = v { app.ui.stroke_width = v as f32; + paint(app, "strokeWidth", serde_json::json!(v)); } + app.auto.add("header.strokeWidth", r, "Stroke Width"); x = r.max.x + 14.0; } if app.ui.tool.puppet_kind().is_some() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6fe8a6a3..de9f0ef4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -190,6 +190,35 @@ fn reveal_time_remap(app: &mut EffectcraftApp, params: &Value) { } } +/// After Alt+Shift+P (A, S, R, T) the Timeline shows the property on each layer it keyed, as in +/// After Effects: added to what a reveal shortcut shows, else with the layer's Transform twirled +/// open, else alone like P. +fn reveal_keyed(app: &mut EffectcraftApp, keyed: &Value, kind: &str) { + let layers: std::collections::BTreeSet = keyed.as_array().into_iter().flatten().filter_map(|k| k.get("layer").and_then(Value::as_u64)).collect(); + let Some(comp) = app.session.active_comp() else { return }; + let tl = &mut app.ui.timeline; + for id in layers { + let shown = tl.layer_reveal.get(&id).filter(|k| !k.is_empty()).cloned(); + match shown { + Some(mut kinds) => { + if !kinds.iter().any(|k| k == kind) { + kinds.push(kind.to_string()); + tl.layer_reveal.insert(id, kinds); + } + } + None if tl.open_layers.contains(&id) => { + if let Some(tr) = comp.layer(effectcraft_engine::project::LayerId(id)).and_then(|l| l.transform()) { + tl.open_groups.insert(tr.uid); + } + } + None => { + tl.open_layers.insert(id); + tl.layer_reveal.insert(id, vec![kind.to_string()]); + } + } + } +} + /// The layers a reveal shortcut acts on: the selected ones, else every layer of the active comp. fn reveal_targets(app: &EffectcraftApp) -> Vec { if app.session.state.selected_layers.is_empty() { @@ -314,7 +343,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return Ok(Value::Null); } if let Some(k) = id.strip_prefix("timeline.keyAt.") { - return run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k})); + let r = run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}))?; + reveal_keyed(app, &r, k); + return Ok(r); } if let Some(k) = id.strip_prefix("timeline.revealAdd.") { reveal(app, k, now, true); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 89322f71..8c2bbf0d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2215,9 +2215,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if graph_on { super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions); } - // Empty-area click in the graph: deselect keys; drag: box-select keys. + // Empty-area click in the graph: deselect keys (below the last row, the layers too, as in + // After Effects); drag: box-select keys. if empty.clicked() { actions.push(("keys.select".into(), json!({"keys": []}))); + let below = empty.interact_pointer_pos().is_some_and(|p| hit_rows.iter().all(|(r, _)| p.y > r.max.y)); + if below && !ui.input(|i| i.modifiers.shift || i.modifiers.command) { + actions.push(("layer.select".into(), json!({"layers": []}))); + } } if let (true, Some(origin), Some(cur)) = (empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos()) diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index dc62062d..246651bb 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -1,10 +1,11 @@ -//! Timeline rows for shape layers: the Contents "Add:" menu and the Classic 3D "Change -//! Renderer…" row (#206). +//! Timeline rows for shape layers and keyframing: the Contents "Add:" menu and the Classic 3D +//! "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds, clicks below the layers +//! deselecting them and the toolbar's Stroke on a selected shape layer (#205). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; use effectcraft_ui_egui::{Dialog, EffectcraftApp}; -use egui::{Event, Modifiers, PointerButton, Pos2, pos2}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use serde_json::json; @@ -78,3 +79,46 @@ fn classic_3d_shape_layers_offer_change_renderer() { assert!(h.state().auto.find(&format!("timeline.layer.{l}.changeRenderer")).is_none()); } +#[test] +fn add_position_key_reveals_position() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(s); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.keyAt.position", json!({})).unwrap(); + h.run_steps(2); + let app = h.state(); + let position = app.session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone(); + assert_eq!(position.keys.len(), 1); + assert_eq!(app.ui.timeline.layer_reveal.get(&l), Some(&vec!["position".to_string()])); + assert!(app.auto.find(&format!("timeline.prop.{}.value.0", position.uid)).is_some(), "the Position row shows"); +} + +#[test] +fn clicking_below_the_layers_deselects_them() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(s); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(l)]); + // In the time graph, below the only layer. + let bar = center(&h, &format!("timeline.layer.{l}.bar")); + click_at(&mut h, bar + egui::vec2(0.0, 80.0)); + assert!(h.state().session.state.selected_layers.is_empty()); +} + +#[test] +fn toolbar_stroke_edits_the_selected_shape_layer() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let mut h = harness(s); + // The Selection tool is active: the options show for the selected shape layer. + click(&mut h, "header.strokeWidth"); + h.input_mut().events.push(Event::Text("4".into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let width = layer.props.prop("contents/group/contents/stroke/width").map(|p| p.value.as_f64()); + assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width"); + assert_eq!(h.state().ui.stroke_width, 4.0); +} From 6e31677718ab854d526c3ee38e08437c6e60ccc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 07/14] Preview: sound starts once rendering keeps real time (#208) Since #103, a preview with audio stayed silent until every frame from the playhead to the end of the preview (or what fits in the RAM preview) was cached. The prefetch only renders a few frames ahead, so on a normal work area the sound came near the end or on the second loop, even when the comp rendered in real time. The held sound now also starts once the silent preview has kept real time for a quarter second with the prefetch window ahead cached. A comp that can't render in real time still waits for the cache, as #103 intended; a stall clears the measured rate so the sound doesn't cut in and out. Test: preview_with_audio_sounds_once_rendering_keeps_up (a 30 s comp sounds within 2 s, without skipping a frame first). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/ui-egui/src/lib.rs | 20 ++++++++++-- crates/ui-egui/tests/ui_audio_scrub.rs | 44 ++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c5bfc40..151aae21 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -139,7 +139,8 @@ pub struct Playback { pub audio_frame: Option, /// The preview's sound waits for frames: while the frames ahead aren't cached, every frame /// shows as it renders, silently; the sound starts once the rest of the preview (or what - /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103). + /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or + /// sooner once the silent preview keeps real time with the frames ahead cached (#208). pub audio_held: bool, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, @@ -168,6 +169,15 @@ impl Playback { let step = self.plan.map_or(1, |p| p.step).max(1) as f64; self.achieved_fps().map(|f| f * step >= self.fps * 0.95) } + + /// The preview has kept real time for the last quarter second (at least two frames) up to + /// `now`: rendering keeps up, so sound can start without waiting for the whole preview. + pub fn keeps_up(&self, now: f64) -> bool { + let step = self.plan.map_or(1, |p| p.step).max(1) as f64; + let frame = step / self.fps.max(1.0); + let (Some(a), Some(b)) = (self.shown_at.front(), self.shown_at.back()) else { return false }; + self.shown_at.len() >= 3 && b - a >= (2.0 * frame).max(0.25) && now - b <= 2.0 * frame && self.real_time() == Some(true) + } } pub struct EffectcraftApp { @@ -1016,10 +1026,12 @@ impl EffectcraftApp { return; } let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; - // Held sound starts once the frames ahead are cached (see `Playback::audio_held`). + // Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the + // prefetch window when the silent preview keeps real time, else the rest of the preview. + let fit = self.frames_that_fit(&c, scale); if self.playback.audio_held && let Some(p) = plan - && self.cached_ahead(&p, &series, cur, self.frames_that_fit(&c, scale)) + && ((self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit)) { self.playback.audio_held = false; self.playback.start_wall = now; @@ -1052,6 +1064,8 @@ impl EffectcraftApp { self.audio = None; self.playback.audio_held = true; self.playback.waiting = true; + // The rate measured before the stall no longer says it keeps up. + self.playback.shown_at.clear(); self.playback.start_wall = now; self.playback.start_frame = cur; } diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index cf17fb18..a2d86b79 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -30,10 +30,15 @@ impl AudioDevice for Fake { } } -/// A 30 fps comp whose solid sounds a tone (Audio switch on). +/// A 2 s, 30 fps comp whose solid sounds a tone (Audio switch on). fn setup(audible: bool, fake: &Fake) -> (EffectcraftApp, ItemId) { + setup_long(audible, fake, 2.0) +} + +/// A `duration`-second, 30 fps comp whose solid sounds a tone (Audio switch on). +fn setup_long(audible: bool, fake: &Fake, duration: f64) -> (EffectcraftApp, ItemId) { let mut s = Session::default(); - s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "frameRate": 30, "duration": 2})).unwrap(); + s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "frameRate": 30, "duration": duration})).unwrap(); let cid = s.active_comp_id().unwrap(); let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); if audible { @@ -140,8 +145,41 @@ fn preview_with_audio_shows_every_frame_and_sounds_once_cached() { } assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached"); assert!(shown > 0, "frames played silently first"); - // A frame that isn't cached stops the sound rather than being skipped. + // A frame that isn't cached stops the sound rather than being skipped. (Frames render fast + // enough here to be cached again before the check: keep them out of the cache.) + h.state().frames.set_budget(0); h.state().frames.clear(); h.step(); assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing); } + +/// A preview whose frames render in real time sounds without waiting for the whole work area to +/// be cached (#208): once the silent preview has kept up for a moment with the frames ahead +/// cached, the sound starts, still without skipping a frame before it. +#[test] +fn preview_with_audio_sounds_once_rendering_keeps_up() { + let fake = Fake::default(); + let (app, cid) = setup_long(true, &fake, 30.0); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.run_steps(2); + let frame = |h: &Harness<'_, EffectcraftApp>| { + let app = h.state(); + app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) + }; + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); + let mut last = frame(&h); + // Two seconds of playback, each step giving the frame workers time to keep up. + for _ in 0..60 { + h.step(); + if h.state().audio.is_some() { + break; + } + let f = frame(&h); + assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped"); + last = f; + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900"); +} From c357a53e5e92cae9612de6bf39d6fefacc413299 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 08/14] 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"); +} From b4d2d58eeb1d4d1dad2210760c2eeaf94e3f28a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 09/14] Masks and Project panel: selecting a mask selects its points; linked Mask Feather; selection box (#203) - Selecting a mask in the Timeline (its row or Mask Path), or a shape's Path, selects all its points, so dragging one in the viewer moves the whole mask, as in After Effects. Delete still removes the mask, and selecting another property deselects the points. - Mask Feather has the chain link (Constrain Proportions, on by default, Alt edits one value) and no longer takes negative values; Scale's link shows as a toggle too. - Dragging in the Project panel's empty area draws a selection box (Shift / Cmd adds); a click there deselects. Tests: selecting_a_mask_selects_its_points, mask_feather_values_are_linked_and_not_negative, dragging_in_the_empty_area_box_selects_items. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/engine/src/commands/edit.rs | 7 ++- crates/engine/src/commands/prop.rs | 29 +++++++++++- crates/engine/src/tests.rs | 31 +++++++++++++ crates/ui-egui/src/panels/project.rs | 42 +++++++++++++++-- crates/ui-egui/src/panels/timeline.rs | 52 ++++++++++++++++++---- crates/ui-egui/src/state.rs | 5 +++ crates/ui-egui/tests/ui_project_rows.rs | 24 ++++++++++ crates/ui-egui/tests/ui_timeline_shapes.rs | 36 +++++++++++++-- 8 files changed, 208 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index d60daf49..21e83a0c 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -136,7 +136,12 @@ fn delete(s: &mut Session, p: &Value) -> Result { if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { return s.execute("keys.delete", json!({})); } - if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() { + // (Points selected with their mask, which selecting a mask does: the mask goes, below.) + let comp = s.active_comp(); + let loose = s.state.selected_vertices.iter().any(|v| { + !s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some()) + }); + if loose && p.get("layers").is_none() { return s.execute("mask.deleteVertices", json!({})); } // Puppet pins selected → delete the pins (not their layer). diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 409210e8..6e82fc2a 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -1,12 +1,12 @@ //! Properties, keyframes and expressions (Animation menu + timeline/Effect Controls gestures). use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key}; -use effectcraft_project::{ItemId, LayerId, Property, Uid}; +use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p}; -use crate::{EngineError, KeyRef, Result, Session, cmd, query}; +use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query}; /// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid). pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { @@ -293,11 +293,19 @@ fn select_prop(s: &mut Session, p: &Value) -> Result { let add = b_p(p, "add").unwrap_or(false); if !add { s.state.selected_props.clear(); + s.state.selected_vertices.clear(); } if !s.state.selected_layers.contains(&lid) { s.state.selected_layers = vec![lid]; } s.state.selected_props.push((lid, uid)); + // Selecting a mask (or its Mask Path, or a shape's Path) selects all its points, so the + // viewer drags the whole mask (as in AE, #203). + let t = s.time(); + if let Some((mask, n)) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(t))) { + s.state.selected_vertices.retain(|v| !(v.layer == lid && v.mask == mask)); + s.state.selected_vertices.extend((0..n).map(|index| VertexRef { layer: lid, mask, index })); + } // Selecting a property selects all its keys (as in AE). if b_p(p, "selectKeys").unwrap_or(true) && let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)) @@ -311,6 +319,23 @@ fn select_prop(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number +/// of points at layer time `lt`. +fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> { + let owner = match l.props.find_group(uid) { + Some(g) => g, + None => l.props.parent_of(uid).filter(|g| g.get("path").is_some_and(|p| p.uid == uid))?, + }; + let in_contents = owner.match_id == "path" && l.props.sub("contents").is_some_and(|c| c.find_group(owner.uid).is_some()); + if !matches!(owner.kind, GroupKind::Mask { .. }) && !in_contents { + return None; + } + match owner.get("path")?.value_at(lt) { + effectcraft_keyframe::Value::Path(p) => Some((owner.uid, p.vertices.len())), + _ => None, + } +} + // ---------------------------------------------------------------- keyframes fn select_keys(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8ae9fcd4..ef467b93 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -217,6 +217,37 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() { let _ = s.render(cid, s.time(), Default::default()); } +/// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the +/// viewer drags the whole mask (#203); a shape's Path item works the same way. +#[test] +fn selecting_a_mask_selects_its_points() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + let m1 = s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap()["mask"].as_u64().unwrap(); + let m2 = s.execute("layer.addMask", json!({"layer": l, "shape": "ellipse"})).unwrap()["mask"].as_u64().unwrap(); + let points = |s: &Session, m: u64| s.state.selected_vertices.iter().filter(|v| v.mask == m).count(); + s.execute("prop.select", json!({"layer": l, "prop": m1, "selectKeys": false})).unwrap(); + assert_eq!((points(&s, m1), s.state.selected_vertices.len()), (4, 4)); + // Mask Path selects them too; Shift-adding another mask keeps the first. + let path = s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().props.find_group(m2).unwrap().get("path").unwrap().uid; + s.execute("prop.select", json!({"layer": l, "prop": path, "add": true})).unwrap(); + assert_eq!((points(&s, m1), points(&s, m2)), (4, 4)); + // Another mask replaces them; another property deselects them. + s.execute("prop.select", json!({"layer": l, "prop": m2})).unwrap(); + assert_eq!((points(&s, m1), points(&s, m2)), (0, 4)); + // Delete removes the selected mask (not only its points). + s.execute("edit.clear", json!({})).unwrap(); + let masks = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(l)).unwrap().masks().unwrap().children.len(); + assert_eq!(masks(&s), 1); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + assert!(s.state.selected_vertices.is_empty()); + let sh = s.execute("layer.newShape", json!({"kind": "star"})).unwrap()["layer"].as_u64().unwrap(); + let star = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "path"})).unwrap()["uid"].as_u64().unwrap(); + s.execute("prop.select", json!({"layer": sh, "prop": star})).unwrap(); + assert_eq!(s.state.selected_vertices.len(), 0, "an empty Path has no points"); +} + #[test] fn save_and_open_roundtrip() { let mut s = demo(); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 2f9963d9..8d84e71d 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -323,9 +323,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h)); let lp = p.with_clip_rect(list); // The list's empty area (under the rows and the scroll bar, which take their own clicks): - // a double-click imports, as in After Effects (File ▸ Import ▸ File...). - let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click()); - app.auto.add("project.empty", list, "Double-click to import files"); + // a click deselects, a drag draws a selection box, a double-click imports, as in After + // Effects (File ▸ Import ▸ File...). + let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::click_and_drag()); + app.auto.add("project.empty", list, "Double-click to import files; drag to select items"); if overflow > 0.0 && ui.rect_contains_pointer(list) { let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift)); let d = if dx.abs() > 0.0 { @@ -718,6 +719,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if empty.double_clicked() { actions.push(("file.import".into(), json!({}))); } + // Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection. + let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if empty.clicked() && !adding { + app.session.state.project_selection.clear(); + } + let box_id = egui::Id::new("proj-marquee"); + if let (Some(start), Some(end)) = (ctx.input(|i| i.pointer.press_origin()), empty.interact_pointer_pos()) + && empty.dragged() + { + let b = Rect::from_two_pos(start, end).intersect(list); + lp.rect_filled(b, 0.0, t.accent.gamma_multiply(0.12)); + lp.rect_stroke(b, 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + ctx.data_mut(|d| d.insert_temp(box_id, b)); + } + if empty.drag_stopped() + && let Some(b) = ctx.data(|d| d.get_temp::(box_id)) + { + ctx.data_mut(|d| d.remove::(box_id)); + // Rows sit at fixed heights from the top of the scrolled list. + let top = list.min.y - app.ui.project_scroll; + let picked = rows.iter().enumerate().filter(|(i, _)| { + let y = top + *i as f32 * ROW_H; + y < b.max.y && y + ROW_H > b.min.y + }); + let picked: Vec = picked.map(|(_, (id, _))| *id).collect(); + let sel = &mut app.session.state.project_selection; + if !adding { + sel.clear(); + } + for id in picked { + if !sel.contains(&id) { + sel.push(id); + } + } + } for (id, params) in actions { if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 8c2bbf0d..233e1384 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2882,6 +2882,26 @@ fn fmt_num(v: f64, d: usize) -> String { format!("{v:.d$}") } +/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio +/// (Constrain Proportions); from 0 the others that are 0 too follow the value. +fn constrained(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter() + .enumerate() + .map(|(e, &x)| { + if e == d { + v + } else if old.abs() > 1e-9 { + x * v / old + } else if x.abs() <= 1e-9 { + v + } else { + x + } + }) + .collect() +} + /// Inline value editor for a property row; pushes `prop.set` actions. fn value_editor( app: &mut EffectcraftApp, @@ -2929,21 +2949,35 @@ fn value_editor( let c = value.components(); let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() }; let pct = matches!(prop.ui, ParamUi::Percent); + // Mask Feather: two linked values, never negative (as in After Effects, #203). + let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. })); + let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) }; + // Scale and Mask Feather: the chain link (Constrain Proportions, on by default). + let linkable = feather || (pct && prop.match_id == "scale"); + let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + if linkable { + let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0)); + let resp = ui.interact(lr, egui::Id::new(("tl-link", uid)), Sense::click()).on_hover_text("Constrain Proportions"); + icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); + app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions"); + if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { + app.ui.timeline.unlinked.insert(uid); + } + x += 18.0; + } for d in 0..n.min(c.len()) { let suffix = if pct && d + 1 == n { "%" } else { "" }; - let (r, nv, _) = - widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, (-1e9, 1e9), 1, suffix, &t); + let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); if let Some(nv) = nv { let mut nc = c.clone(); - if pct && prop.match_id == "scale" && !ui.input(|i| i.modifiers.alt) { - // Constrain proportions (AE's chain link, on by default). - let k = if c[d].abs() > 1e-9 { nv / c[d] } else { 1.0 }; - for e in 0..n { - nc[e] = if e == d { nv } else { c[e] * k }; + // Alt edits one value of a linked pair. + let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 }; + let from = if shown == 1 { d } else { 0 }; + for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + if let Some(slot) = nc.get_mut(from + e) { + *slot = v; } - } else { - nc[d] = nv; } set(actions, json!(nc)); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 041ce593..398d724f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -413,6 +413,10 @@ pub struct TimelineState { /// Properties whose inline expression editor is collapsed. #[serde(default)] pub expr_closed: BTreeSet, + /// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default) + /// was turned off. + #[serde(default)] + pub unlinked: BTreeSet, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -458,6 +462,7 @@ impl Default for TimelineState { graph_reference: false, graph_transform_box: true, expr_closed: BTreeSet::new(), + unlinked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, } diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index cd15d46e..074b41b3 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -95,3 +95,27 @@ fn label_swatch_opens_the_label_menu() { assert_eq!(h.state().session.history.undo.len(), undo + 1); assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice"); } + +/// Dragging from the Project panel's empty area draws a selection box that selects the rows it +/// touches; a click there deselects (#203). +#[test] +fn dragging_in_the_empty_area_box_selects_items() { + let (mut h, folder, _, solid) = harness(); + let list = h.state().auto.find("project.empty").unwrap().rect; + let from = pos2(list[0] + 40.0, list[1] + list[3] - 6.0); + let to = center(&h, &format!("project.item.{}.name", folder.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + button(&mut h, from, true); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + button(&mut h, to, false); + h.run_steps(2); + let sel = h.state().session.state.project_selection.clone(); + assert!(sel.contains(&folder) && sel.contains(&solid), "{sel:?}"); + click_at(&mut h, from); + assert!(h.state().session.state.project_selection.is_empty()); +} diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 246651bb..27389247 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -1,6 +1,6 @@ -//! Timeline rows for shape layers and keyframing: the Contents "Add:" menu and the Classic 3D -//! "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds, clicks below the layers -//! deselecting them and the toolbar's Stroke on a selected shape layer (#205). +//! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic +//! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the +//! layers deselecting them (#205), and Mask Feather's linked values (#203). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -106,6 +106,36 @@ fn clicking_below_the_layers_deselects_them() { assert!(h.state().session.state.selected_layers.is_empty()); } +#[test] +fn mask_feather_values_are_linked_and_not_negative() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.addMask", json!({"layer": l, "shape": "rect"})).unwrap(); + let feather = |h: &Harness<'_, EffectcraftApp>| { + let p = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("masks/#1/feather").unwrap().clone(); + (p.uid, p.value.components()) + }; + let mut h = harness(s); + h.state_mut().ui.timeline.open_layers.insert(l); + h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["feather".into()]); + h.run_steps(2); + let (uid, _) = feather(&h); + let type_value = |h: &mut Harness<'_, EffectcraftApp>, d: usize, v: &str| { + click(h, &format!("timeline.prop.{uid}.value.{d}")); + h.input_mut().events.push(Event::Text(v.into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + type_value(&mut h, 0, "12"); + assert_eq!(feather(&h).1, vec![12.0, 12.0], "linked by default"); + type_value(&mut h, 1, "-5"); + assert_eq!(feather(&h).1, vec![0.0, 0.0], "never negative"); + click(&mut h, &format!("timeline.prop.{uid}.link")); + type_value(&mut h, 1, "7"); + assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked"); +} + #[test] fn toolbar_stroke_edits_the_selected_shape_layer() { let mut s = session(); From a831d129756001b7ee4f4f8634a8db6faca94661 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 10/14] Puppet: dragging pins keeps the layer under the Puppet cached (#212) Every pin move re-rendered the Puppet's input (source, masks and the effects before it) at full size on the UI thread and deep-copied it for the mesh overlay: the cache key of a layer's input hashed all of its effects, so moving a pin of the Puppet changed the key of its own input. The input key now covers only the effects that run before the Puppet, and the overlay shares the cached buffer. Test: input_keys_ignore_later_effects. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- crates/engine/src/commands/puppet.rs | 7 +++--- crates/render/src/cache.rs | 33 +++++++++++++++++++++++----- crates/render/src/tests_time.rs | 28 +++++++++++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index 737cc7cf..8d11fc57 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -26,8 +26,9 @@ fn pt(v: Option<&Value>) -> Option<[f64; 2]> { Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) } -/// The Puppet effect's input pixels and evaluated parameters at comp time `t`. -pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(Buf, Params)> { +/// The Puppet effect's input pixels and evaluated parameters at comp time `t`. The input comes +/// from the layer cache, shared: moving pins doesn't change it (#212). +pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) -> Option<(std::sync::Arc, Params)> { let comp = s.project.comp(cid)?; let layer = comp.layer(lid)?; let fx = layer.effects()?; @@ -37,7 +38,7 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); - let buf = (*r.layer_input(&ctx, layer, index)?).clone(); + let buf = r.layer_input(&ctx, layer, index)?; let params = effectcraft_effects::flatten_params(g, &mut |p| ctx.value(layer, p)); Some((buf, params)) } diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index b30f67cb..97908480 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -492,7 +492,10 @@ pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bo /// effects (see `Renderer::layer_input`). Unlike [`layer_key`], footage layers are cached here /// (keyed by item and source time), since Time effects read many neighbouring frames. pub fn input_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, effects: usize) -> Option { - let base = key_with(ctx, layer, scale, draft, blur, true)?; + if layer.switches.adjustment { + return None; + } + let base = key_any(ctx, layer, scale, draft, blur, true, Some(effects))?; let mut h = KeyHasher(base ^ 0x5bd1_e995_7a3c_11d3); effects.hash(&mut h); Some(h.finish()) @@ -516,18 +519,20 @@ fn has_expression(g: &PropGroup) -> bool { /// Cache key for an adjustment layer's footprint (its source through its masks, see /// `Renderer::adjustment_footprint`): the GPU compositor then uploads it once, not every frame. pub fn footprint_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option { - Some(derive(key_any(ctx, layer, scale, draft, blur, false)?, 0xf007_9417)) + Some(derive(key_any(ctx, layer, scale, draft, blur, false, None)?, 0xf007_9417)) } fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { if layer.switches.adjustment { return None; } - key_any(ctx, layer, scale, draft, blur, footage) + key_any(ctx, layer, scale, draft, blur, footage, None) } -/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). -fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool) -> Option { +/// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). `effects`: key only +/// the first that many effects (a layer's input, see [`input_key`]). +#[allow(clippy::too_many_arguments)] +fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option) -> Option { let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); match &layer.source { LayerSource::Solid { item } => { @@ -576,6 +581,24 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo { continue; } + // A layer's input runs only its first effects: the later ones (a Puppet whose pins are + // dragged, #212) don't change it, so it stays cached. + if let (Some(n), Node::Group(g)) = (effects, c) + && g.match_id == "effects" + { + let mut first = g.clone(); + let mut seen = 0; + first.children.retain(|e| match e { + Node::Prop(_) => true, + Node::Group(_) => { + seen += 1; + seen <= n + } + }); + hash_debug(&mut h, &first); + hash_values(&mut h, ctx, layer, &first); + continue; + } // Static structure: values, keyframes, expressions, enabled flags, effect ids, modes. hash_debug(&mut h, c); if let Node::Group(g) = c { diff --git a/crates/render/src/tests_time.rs b/crates/render/src/tests_time.rs index b2833fb0..31ed9db3 100644 --- a/crates/render/src/tests_time.rs +++ b/crates/render/src/tests_time.rs @@ -103,3 +103,31 @@ fn cache_correct_while_scrubbing() { assert_eq!(render(&p, cid, t, Some(&cache)).data, render(&p, cid, t, None).data, "edited t={t}"); } } + +/// A layer's input up to an effect keys only the effects before it: editing a later effect (a +/// Puppet pin being dragged, #212) keeps the input cached; editing an earlier one doesn't. +#[test] +fn input_keys_ignore_later_effects() { + let (mut p, cid) = scene(Some(("ec.blur.gaussian", &[("blurriness", Value::Scalar(4.0))]))); + { + let spec = effectcraft_effects::find("ec.stylize.mosaic").unwrap(); + let mut next = p.next_id; + let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(g.into()); + } + let key = |p: &Project| { + let comp = p.comp(cid).unwrap(); + let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None }; + let l = &comp.layers[0]; + (crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2)) + }; + let (one, two) = key(&p); + assert!(one.is_some() && one != two); + let set = |p: &mut Project, path: &str, v: f64| p.comp_mut(cid).unwrap().layers[0].props.prop_mut(path).unwrap().value = Value::Scalar(v); + set(&mut p, "effects/#2/horizontal", 30.0); + assert_eq!(key(&p).0, one, "the second effect doesn't change the input to it"); + assert_ne!(key(&p).1, two); + set(&mut p, "effects/#1/blurriness", 8.0); + assert_ne!(key(&p).0, one); +} From 603a9bfdb418a0c67263170327f62b837fbe2d40 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:13:19 +0000 Subject: [PATCH 11/14] Docs: gaps.md records the user reports of 7 October MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #198, #201, #203–#206, #208, #209 and #212, with their regression tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- docs/gaps.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 6476c2ff..119d38fa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -34,7 +34,7 @@ of them have been reconciled. | Behaves like After Effects | largely unmeasured, ≈ 60–80% | One outside contributor found four bugs in features marked done within a day (PRs #6–#10): Hold keyframes eased the motion into them, `keyInSpatialTangent` / `keyOutSpatialTangent` had the wrong names, a zero frame rate crashed, Find and Enter Full Screen shared Ctrl+F off macOS. There are about 10 After Effects reference captures in total. The first live G1 comparison now checks eight scalar Rotation/ease cases (168 samples) against AE 26.3x87 via AEsync 2.0.4: all pass after fixing overlapping temporal influences, with maximum error below 3e-11 degrees ([scores and reproduction](fidelity/README.md)). Other animation behavior and rendered frames remain unmeasured. User reports of 6 October found five more in features marked done (the Render Queue's template menu, angle revolutions, switches wiping the RAM preview, audio playback skipping frames, preview crashing when video memory ran out; all fixed 7 October) | | Opening existing After Effects work | ≈ 0% | `.aep` / `.aepx` projects cannot be opened. Third-party After Effects plug-ins cannot run. Expressions and the scripting object model are strong, so scripts and expressions carry over | | Stability | improving | 23 never-crash PRs landed on 4 October; community PRs on 7 October fixed a hang on projects whose parent chains loop (#142), a panic on non-ASCII label colours (#135), and contained GPU initialization and device failures (#139); [AGENTS.md](../AGENTS.md) "Never crash" now binds every crate. On 5 October `cargo xtask ci` failed on main under Rust 1.99's clippy (a fix is in progress) | -| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Most checking happens on macOS. No localisation, no accessibility work | +| Real-user experience | ≈ 70%, uneven by platform | Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Most checking happens on macOS. No localisation, no accessibility work | | Performance | unknown against After Effects | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU, an M4 Pro under load). Nothing benchmarked against After Effects; no large real projects (4K footage, hundreds of layers) tested | | Media formats | ≈ 80% | H.264, ProRes, HEVC, AV1, image sequences and audio exist. The new HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified | | AI-assisted tools | ≈ 50% | Both tools can use trained models (pure-Rust inference, optional downloads), but nothing compares them with After Effects yet. Roto Brush 2.0 / 3.0 with MobileSAM (M13.35) scores IoU 0.989 on the base frame of our synthetic moving-disc test and ≥ 0.980 over 20 propagated frames (classic: 0.973). Face tracking with MediaPipe Face Landmarker (M13.36) matches Google's own pipeline to 0.85 px on average on a test portrait; on our synthetic clip the eyes and chin stay within 4% of the face height | @@ -65,8 +65,8 @@ The most important missing piece: it turns every other estimate here into a meas - Fix user issues as they come in, each with a regression test, and answer the reporter (#41–#47 and #63–#68 are handled, as are the Windows report that the font menus missed installed fonts - and the reports of 6 October, #85, #88, #89, #93, #103, #106 and #117; Wayland file drops wait - on winit). + and the reports of 6 October, #85, #88, #89, #93, #103, #106 and #117, and of 7 October, #198, + #201, #203–#206, #208, #209 and #212; Wayland file drops wait on winit). - Run the headless snapshot and control-channel checks on Linux (X11 and Wayland) and Windows, not only macOS. Interactions that only fail with real input (docking drags, viewer pan, drag-and-drop import, inline rename) need scripted input tests. @@ -90,6 +90,14 @@ The most important missing piece: it turns every other estimate here into a meas Windows, verify that Escape cancels edits and non-finite entries leave the value and undo history unchanged in Effect Controls, Timeline and Properties. Valid entries still commit on Enter or click-away. +- Regression evidence for the reports of 7 October, checked headlessly on Linux: `ui_viewer` + `full_resolution_frames_wider_than_the_texture_limit_fit` (#201), the desktop app's + `no_arguments_start_an_empty_project` (#204) and `device_limits_fit_the_adapter` (#198), + `audio_spectrum_follows_time_through_the_layer_cache` (#209), + `preview_with_audio_sounds_once_rendering_keeps_up` (#208), `ui_timeline_shapes` (the Add menu + and Change Renderer, #206; deselecting below the layers, Alt+Shift+P and the toolbar's stroke, + #205; linked Mask Feather, #203), `dragging_in_the_empty_area_box_selects_items` and + `selecting_a_mask_selects_its_points` (#203), and `input_keys_ignore_later_effects` (#212). - Done when: no open bug blocks a basic workflow (import, arrange, animate, preview, render) on any of the three desktop platforms, and every bug users report gets triaged within a day. From a2f22bb35bffe961a18dc77c4f7909bbf8c5ba03 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:33:04 +0000 Subject: [PATCH 12/14] Web: a replaced file goes to reused workers again, even at the same size (#218) Job workers remembered the files they were sent by path and byte length, so footage replaced by different bytes of the same length at the same path was never resent: a Render Queue export through a reused worker rendered the old image while the page showed the new one. Frame workers remembered paths only, so they never got a replacement at all. Every write of a stored file now gets a version, and both kinds of worker remember the version they were sent. Tests: versions_change_with_every_write (store) and apps/effectcraft-web/tests/workers.mjs (Node, fake Worker: the replacement is sent once, an unchanged file not again). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- apps/effectcraft-web/js/host.js | 14 ++++--- apps/effectcraft-web/src/files.rs | 6 +++ apps/effectcraft-web/src/frames.rs | 18 ++++----- apps/effectcraft-web/src/store.rs | 35 +++++++++++++++- apps/effectcraft-web/src/worker.rs | 4 +- apps/effectcraft-web/tests/workers.mjs | 56 ++++++++++++++++++++++++++ 6 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 apps/effectcraft-web/tests/workers.mjs diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index cdc71f9d..486865ae 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -278,8 +278,8 @@ export function audioStop() { // ------------------------------------------------------------------ workers -// Idle workers are reused; each keeps the files it was sent (path → size) so a file goes to a -// worker once. +// Idle workers are reused; each keeps the files it was sent (path → version) so a file goes to a +// worker once, and again when it was replaced (even by one of the same size, #218). const idle = []; const running = new Map(); // job id → {worker, done} // Job workers open a WebGPU device of their own (unless `?nogpuworkers`): their jobs' frames @@ -310,7 +310,8 @@ function spawn(base) { return w; } -/// Run a job in a worker. `files`: [[path, Uint8Array]] the job reads; `onMessage(type, json, +/// Run a job in a worker. `files`: [[path, Uint8Array, version]] the job reads (`version` +/// changes with every write of the file); `onMessage(type, json, /// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download), /// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message). export function workerRun(id, json, files, base, onMessage) { @@ -339,9 +340,10 @@ export function workerRun(id, json, files, base, onMessage) { onMessage("error", String(e.message || "worker error"), null, null); w.worker.terminate(); }; - for (const [path, bytes] of files) { - if (w.files.get(path) === bytes.length) continue; - w.files.set(path, bytes.length); + for (const [path, bytes, version] of files) { + const tag = version ?? bytes.length; + if (w.files.get(path) === tag) continue; + w.files.set(path, tag); w.worker.postMessage({ type: "file", path, bytes }); } w.worker.postMessage({ type: "job", json }); diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs index ef0889ba..ca6699ea 100644 --- a/apps/effectcraft-web/src/files.rs +++ b/apps/effectcraft-web/src/files.rs @@ -35,6 +35,12 @@ pub fn get(path: &str) -> Option> { STORE.file(path) } +/// A file's bytes and version (new with every write: workers resend a file whose version they +/// haven't seen). +pub fn get_versioned(path: &str) -> Option<(Arc<[u8]>, u64)> { + STORE.file_versioned(path) +} + /// `(path, size)` of every file in the table. pub fn list() -> Vec<(String, usize)> { STORE.files().into_iter().map(|(p, n, _, _)| (p, n)).collect() diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs index fc703eba..523fd03c 100644 --- a/apps/effectcraft-web/src/frames.rs +++ b/apps/effectcraft-web/src/frames.rs @@ -19,7 +19,7 @@ //! frame times. Without WebGPU, frames render on the worker's CPU. use std::cell::RefCell; -use std::collections::HashSet; +use std::collections::HashMap; use std::sync::Arc; use effectcraft_engine::offload::footage_files; @@ -45,7 +45,8 @@ extern "C" { struct Fw { index: u32, mirror: Mirror, - files: HashSet, + /// The footage files it was sent, with their versions (a replaced file goes again). + files: HashMap, busy: Option<(u64, RemoteDone)>, alive: bool, /// Its GPU adapter (after it reported), or why it has none. @@ -99,7 +100,7 @@ impl WebFrames { let mut s = s.borrow_mut(); for _ in 0..n { let index = frame_worker_start(&crate::page_url(""), on.as_ref().unchecked_ref(), gpu); - s.workers.push(Fw { index, mirror: Mirror::default(), files: HashSet::new(), busy: None, alive: true, gpu: None }); + s.workers.push(Fw { index, mirror: Mirror::default(), files: HashMap::new(), busy: None, alive: true, gpu: None }); } }); HANDLER.with(|h| *h.borrow_mut() = Some(on)); @@ -116,14 +117,13 @@ impl WebFrames { // Footage the worker hasn't got yet. let w = &mut s.workers[k]; for p in footage_files(&job.project) { - if w.files.contains(&p) { + let Some((d, version)) = crate::files::get_versioned(&p) else { continue }; + if w.files.get(&p) == Some(&version) { continue; } - if let Some(d) = crate::files::get(&p) { - let bytes = js_sys::Uint8Array::from(&d[..]); - frame_worker_post(w.index, &obj(&[("type", "file".into()), ("path", p.as_str().into()), ("bytes", bytes.into())]), &js_sys::Array::new()); - w.files.insert(p); - } + let bytes = js_sys::Uint8Array::from(&d[..]); + frame_worker_post(w.index, &obj(&[("type", "file".into()), ("path", p.as_str().into()), ("bytes", bytes.into())]), &js_sys::Array::new()); + w.files.insert(p, version); } let sync = w.mirror.sync(job.revision, &job.project); let index = w.index; diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index ae02649b..7017b620 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -42,6 +42,9 @@ pub struct Entry { pub modified: f64, /// Written to the browser's storage (render outputs are not). pub persist: bool, + /// Which write this is: new with every write of the key, so a file replaced by another of + /// the same size still reads as changed (workers are sent it again, #218). + pub version: u64, } /// A queued write for the browser storage: `data` = `None` deletes the key. @@ -58,17 +61,21 @@ pub struct Mirror { entries: BTreeMap, /// Keys changed since the last [`Mirror::take_pending`], in first-change order. dirty: Vec, + /// The last [`Entry::version`] given. + version: u64, } impl Mirror { /// An entry read from the browser's storage at startup (nothing to flush). pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) { - self.entries.insert(key.to_string(), Entry { data, modified, persist: true }); + let version = self.next_version(); + self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version }); } pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) { let was_persisted = self.entries.get(key).is_some_and(|e| e.persist); - self.entries.insert(key.to_string(), Entry { data, modified, persist }); + let version = self.next_version(); + self.entries.insert(key.to_string(), Entry { data, modified, persist, version }); if persist || was_persisted { self.touch(key); } @@ -86,6 +93,11 @@ impl Mirror { } } + fn next_version(&mut self) -> u64 { + self.version = self.version.wrapping_add(1); + self.version + } + fn touch(&mut self, key: &str) { if !self.dirty.iter().any(|k| k == key) { self.dirty.push(key.to_string()); @@ -172,6 +184,11 @@ impl Store { self.lock().get(&file_key(path)).map(|e| e.data.clone()) } + /// The file under `path` with its [`Entry::version`]. + pub fn file_versioned(&self, path: &str) -> Option<(Arc<[u8]>, u64)> { + self.lock().get(&file_key(path)).map(|e| (e.data.clone(), e.version)) + } + pub fn remove_file(&self, path: &str) -> bool { let r = self.lock().remove(&file_key(path)); self.changed(); @@ -412,6 +429,20 @@ mod tests { assert_eq!(m.list("files").len(), 1); } + /// A file replaced by different bytes of the same size gets a new version, so workers are + /// sent it again (#218). + #[test] + fn versions_change_with_every_write() { + let s = store(); + s.put_file("/collision.png", Arc::from(&b"red!"[..]), true); + let (_, a) = s.file_versioned("/collision.png").unwrap(); + s.put_file("/collision.png", Arc::from(&b"blue"[..]), true); + let (data, b) = s.file_versioned("/collision.png").unwrap(); + assert_ne!(a, b); + assert_eq!(&data[..], b"blue"); + assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it"); + } + #[test] fn keys_and_paths_round_trip() { assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov"); diff --git a/apps/effectcraft-web/src/worker.rs b/apps/effectcraft-web/src/worker.rs index 81c5fb31..bd86fd5d 100644 --- a/apps/effectcraft-web/src/worker.rs +++ b/apps/effectcraft-web/src/worker.rs @@ -60,8 +60,8 @@ impl Offload for WorkerOffload { let id = req.id; let files = js_sys::Array::new(); for p in &req.files { - if let Some(d) = crate::files::get(p) { - files.push(&js_sys::Array::of2(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..]))); + if let Some((d, version)) = crate::files::get_versioned(p) { + files.push(&js_sys::Array::of3(&JsValue::from_str(p), &js_sys::Uint8Array::from(&d[..]), &JsValue::from_f64(version as f64))); } } let json = serde_json::to_string(&req).map_err(|e| e.to_string())?; diff --git a/apps/effectcraft-web/tests/workers.mjs b/apps/effectcraft-web/tests/workers.mjs new file mode 100644 index 00000000..96e3f1f0 --- /dev/null +++ b/apps/effectcraft-web/tests/workers.mjs @@ -0,0 +1,56 @@ +// Job-worker plumbing of the web app (js/host.js) under Node with a fake Worker, +// no browser or Wasm build needed: +// +// node apps/effectcraft-web/tests/workers.mjs +// +// - A reused worker is sent a file again when it was replaced, even by one of the same size +// (#218), and not when it is unchanged. +// Exits non-zero on failure. +import assert from "node:assert/strict"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +globalThis.self = globalThis; + +// A Worker that starts on "init" and finishes each job at once. +let failStart = false; +const workers = []; +class FakeWorker { + constructor() { + this.posted = []; + this.terminated = false; + workers.push(this); + } + postMessage(m) { + this.posted.push(m); + setTimeout(() => { + if (m.type === "init") { + if (failStart) this.onerror?.({ message: "worker failed to start" }); + else this.onmessage?.({ data: { type: "ready" } }); + } else if (m.type === "job") { + this.onmessage?.({ data: { type: "reply", json: "{}", done: true } }); + } + }); + } + terminate() { + this.terminated = true; + } +} +globalThis.Worker = FakeWorker; + +const host = await import(pathToFileURL(join(here, "../js/host.js"))); +const run = (id, files) => + new Promise((resolve) => host.workerRun(id, "{}", files, "http://localhost/", (kind, json) => kind !== "file" && resolve({ kind, json }))); +const sent = (w) => w.posted.filter((m) => m.type === "file").map((m) => m.bytes[0]); + +// #218: equal-length replacement. +const red = new Uint8Array(512).fill(1); +const blue = new Uint8Array(512).fill(2); +assert.equal((await run(1, [["/collision.png", red, 1]])).kind, "reply"); +assert.equal((await run(2, [["/collision.png", blue, 2]])).kind, "reply"); +assert.equal((await run(3, [["/collision.png", blue, 2]])).kind, "reply"); +assert.equal(workers.length, 1, "the worker is reused"); +assert.deepEqual(sent(workers[0]), [1, 2], "the replacement is sent once, the unchanged file not again"); + +console.log("workers: ok"); From 72b935fe263fbc25787138f7cd7e02a406d7f8cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:33:04 +0000 Subject: [PATCH 13/14] Web: a job worker that fails to start fails its job (#216) worker.js left a rejected start (its async init) unhandled, which a page's Worker error event doesn't see: the page waited for "ready" forever and the render stayed pending until cancelled. The failed start is now re-raised where the page's onerror sees it, like the other worker failures; the job fails with the error and the worker is terminated. Test: apps/effectcraft-web/tests/workers.mjs (worker.js with a stub module whose init rejects; host.js with a worker whose start fails). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- apps/effectcraft-web/js/host.js | 2 ++ apps/effectcraft-web/tests/workers.mjs | 49 ++++++++++++++++++++++++-- apps/effectcraft-web/web/worker.js | 3 ++ docs/web.md | 5 +++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 486865ae..702c5d33 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -348,7 +348,9 @@ export function workerRun(id, json, files, base, onMessage) { } w.worker.postMessage({ type: "job", json }); }, (e) => { + // The worker didn't start (#216): the job fails and the worker goes. running.delete(id); + w.worker.terminate(); onMessage("error", String(e.message || e), null, null); }); } diff --git a/apps/effectcraft-web/tests/workers.mjs b/apps/effectcraft-web/tests/workers.mjs index 96e3f1f0..15a77f1d 100644 --- a/apps/effectcraft-web/tests/workers.mjs +++ b/apps/effectcraft-web/tests/workers.mjs @@ -1,19 +1,24 @@ -// Job-worker plumbing of the web app (js/host.js) under Node with a fake Worker, +// Job-worker plumbing of the web app (js/host.js, web/worker.js) under Node with a fake Worker, // no browser or Wasm build needed: // // node apps/effectcraft-web/tests/workers.mjs // // - A reused worker is sent a file again when it was replaced, even by one of the same size // (#218), and not when it is unchanged. +// - A worker whose start fails fails its job and is terminated, instead of the job waiting for +// "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start. // Exits non-zero on failure. import assert from "node:assert/strict"; +import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); +const tick = () => new Promise((r) => setTimeout(r, 0)); globalThis.self = globalThis; -// A Worker that starts on "init" and finishes each job at once. +// A Worker that starts (or fails to) on "init" and finishes each job at once. let failStart = false; const workers = []; class FakeWorker { @@ -53,4 +58,44 @@ assert.equal((await run(3, [["/collision.png", blue, 2]])).kind, "reply"); assert.equal(workers.length, 1, "the worker is reused"); assert.deepEqual(sent(workers[0]), [1, 2], "the replacement is sent once, the unchanged file not again"); +// #216: a worker that fails to start. +failStart = true; +const slow = run(4, []); // the idle worker takes this one +const failed = await run(5, []); +await slow; +assert.equal(failed.kind, "error"); +assert.ok(workers[1].terminated, "the failed worker is terminated"); +assert.equal(host.workerStats().running, 0); + +// #216, worker side: a rejected start is re-raised where the page's `onerror` sees it. +const dir = mkdtempSync(join(tmpdir(), "ec-worker-")); +copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js")); +writeFileSync( + join(dir, "effectcraft_web.js"), + `export default async function init() {} +export function workerInit() {} +export async function workerJobInit() { throw new Error("no device"); } +export async function workerFrameInit() {}`, +); +const posted = []; +globalThis.postMessage = (m) => posted.push(m); +// A browser's Worker `error` event only sees thrown errors, not unhandled rejections (Node would +// turn one into an uncaught exception unless it is listened to). +const unhandled = []; +process.on("unhandledRejection", (e) => unhandled.push(e)); +const raised = new Promise((resolve) => { + process.once("uncaughtException", resolve); + setTimeout(() => resolve(new Error("nothing was raised")), 1000); +}); +await import(pathToFileURL(join(dir, "worker.js"))); +self.onmessage({ data: { type: "init", base: "http://localhost/" } }); +const err = await raised; +await tick(); +// (So a failed assertion below fails the run.) +process.removeAllListeners("unhandledRejection"); +process.removeAllListeners("uncaughtException"); +assert.deepEqual(unhandled, [], "the failed start is handled, not left as an unhandled rejection"); +assert.equal(err.message, "no device"); +assert.ok(!posted.some((m) => m.type === "ready"), "no ready after a failed start"); + console.log("workers: ok"); diff --git a/apps/effectcraft-web/web/worker.js b/apps/effectcraft-web/web/worker.js index 7c6752e5..2d2c154b 100644 --- a/apps/effectcraft-web/web/worker.js +++ b/apps/effectcraft-web/web/worker.js @@ -30,6 +30,9 @@ self.onmessage = (e) => { else await ec.workerJobInit(!!m.gpu); self.postMessage({ type: "ready" }); })(); + // A failed start is re-raised outside the promise so the page's `onerror` sees it: its job + // fails instead of waiting for "ready" forever (#216). + ready.catch((err) => setTimeout(() => { throw err; })); return; } // A failure (a panic) is re-raised outside the queue so the page's `onerror` sees it, as diff --git a/docs/web.md b/docs/web.md index 104cbc2c..12d2b24d 100644 --- a/docs/web.md +++ b/docs/web.md @@ -413,6 +413,11 @@ cargo xtask web --serve 8765 & node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out target/web/smoke ``` +`apps/effectcraft-web/tests/workers.mjs` checks the job-worker plumbing (`js/host.js`, +`web/worker.js`) under Node with a fake `Worker`, without a build: a replaced file is sent to a +reused worker again even at the same size, and a worker that fails to start fails its job and is +terminated (`node apps/effectcraft-web/tests/workers.mjs`). + It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that replies when done while the page keeps running, and the Media Browser over browser storage; and From 1ca78821b9768e9b6ad427c10aafd65b51f8804b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:33:04 +0000 Subject: [PATCH 14/14] Web: a failed storage write is retried and flush() rejects (#215) A write to browser storage that failed (quota exceeded) was dropped: effectcraft.flush() resolved anyway, nothing retried it, and a reload restored the project from before the change. The failed key now stays pending and is written again after 2 s (doubling to a minute while it keeps failing; a newer change of the key goes with it), and flush() rejects with the error. Test: failed_writes_retry (store). The flush itself runs only in the browser. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f --- apps/effectcraft-web/src/api.rs | 3 +- apps/effectcraft-web/src/persist.rs | 73 +++++++++++++++++++++++++---- apps/effectcraft-web/src/store.rs | 23 +++++++++ docs/web.md | 2 +- 4 files changed, 90 insertions(+), 11 deletions(-) diff --git a/apps/effectcraft-web/src/api.rs b/apps/effectcraft-web/src/api.rs index 57cfd98d..793e557c 100644 --- a/apps/effectcraft-web/src/api.rs +++ b/apps/effectcraft-web/src/api.rs @@ -148,7 +148,8 @@ pub fn remove_stored(path: String) -> bool { crate::files::STORE.remove_file(&path) } -/// Resolves once every change is written to browser storage. +/// Resolves once every change is written to browser storage; rejects when a write failed (it is +/// tried again). #[wasm_bindgen] pub fn flush() -> js_sys::Promise { crate::persist::flushed() diff --git a/apps/effectcraft-web/src/persist.rs b/apps/effectcraft-web/src/persist.rs index 6ee2c86b..93fe8906 100644 --- a/apps/effectcraft-web/src/persist.rs +++ b/apps/effectcraft-web/src/persist.rs @@ -3,7 +3,8 @@ //! //! - [`load`] (before the app starts) opens the storage and reads every entry into the mirror. //! - Changes flush in the background ([`schedule_flush`], wired as the store's change hook): -//! one write per changed key, in order, with nothing written twice concurrently. +//! one write per changed key, in order, with nothing written twice concurrently. A write that +//! fails (quota exceeded…) is kept and tried again, after 2 s, then longer, up to a minute. //! - The session snapshot ([`snapshot`]) records the open project and editor state every second //! or so while it changes, so a reload (or a crash) comes back to where the user was //! ([`restore`]). @@ -37,11 +38,18 @@ thread_local! { static FLUSHING: Cell = const { Cell::new(false) }; static BACKEND: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; static LAST_ERROR: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - static FLUSH_WAITERS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + /// [`flushed`] promises waiting for the running flush: (resolve, reject). + static FLUSH_WAITERS: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + /// Delay before retrying failed writes (ms; doubles while they keep failing). + static RETRY_MS: Cell = const { Cell::new(RETRY_FIRST_MS) }; + static RETRY_PENDING: Cell = const { Cell::new(false) }; static SNAP: Cell<(u64, f64)> = const { Cell::new((u64::MAX, 0.0)) }; static LAST_META: std::cell::RefCell = const { std::cell::RefCell::new(String::new()) }; } +const RETRY_FIRST_MS: i32 = 2_000; +const RETRY_MAX_MS: i32 = 60_000; + /// The storage in use: `opfs`, `indexeddb` or `memory`. pub fn backend() -> String { BACKEND.with(|b| b.borrow().clone()) @@ -86,8 +94,11 @@ pub fn schedule_flush() { return; // the running flush picks the new changes up } wasm_bindgen_futures::spawn_local(async { + // Keys whose write failed in this flush, and the last failure. + let mut failed: Vec = vec![]; + let mut error = None; loop { - let pending = STORE.lock().take_pending(); + let pending: Vec<_> = STORE.lock().take_pending().into_iter().filter(|p| !failed.contains(&p.key)).collect(); if pending.is_empty() { break; } @@ -99,24 +110,68 @@ pub fn schedule_flush() { if let Err(e) = r { let msg = format!("saving {} to browser storage failed: {}", p.key, js_err(e)); log::warn!("{msg}"); - LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg)); + LAST_ERROR.with(|l| *l.borrow_mut() = Some(msg.clone())); + error = Some(msg); + failed.push(p.key); } } } + // Failed writes stay pending (#215): flushed later, and the waiters hear of the failure + // instead of being told the changes are stored. + { + let mut m = STORE.lock(); + for k in &failed { + m.retry(k); + } + } FLUSHING.with(|f| f.set(false)); - for w in FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())) { - let _ = w.call0(&JsValue::NULL); + let waiters = FLUSH_WAITERS.with(|w| std::mem::take(&mut *w.borrow_mut())); + match &error { + None => { + RETRY_MS.with(|r| r.set(RETRY_FIRST_MS)); + for (resolve, _) in waiters { + let _ = resolve.call0(&JsValue::NULL); + } + } + Some(msg) => { + let e: JsValue = js_sys::Error::new(msg).into(); + for (_, reject) in waiters { + let _ = reject.call1(&JsValue::NULL, &e); + } + retry_later(); + } } }); } -/// A promise that resolves once every change so far is in the browser storage. +/// Flush again after the retry delay (once; the delay doubles up to a minute while writes keep +/// failing). +fn retry_later() { + if RETRY_PENDING.with(|r| r.replace(true)) { + return; + } + let ms = RETRY_MS.with(|r| { + let ms = r.get(); + r.set(ms.saturating_mul(2).min(RETRY_MAX_MS)); + ms + }); + let again = Closure::once_into_js(|| { + RETRY_PENDING.with(|r| r.set(false)); + schedule_flush(); + }); + if let Some(w) = web_sys::window() { + let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(again.unchecked_ref(), ms); + } +} + +/// A promise that resolves once every change so far is in the browser storage, and rejects +/// when a write failed (the change stays pending and is tried again). pub fn flushed() -> js_sys::Promise { - js_sys::Promise::new(&mut |resolve, _| { + js_sys::Promise::new(&mut |resolve, reject| { if !FLUSHING.with(|f| f.get()) && !STORE.lock().has_pending() { let _ = resolve.call0(&JsValue::NULL); } else { - FLUSH_WAITERS.with(|w| w.borrow_mut().push(resolve)); + FLUSH_WAITERS.with(|w| w.borrow_mut().push((resolve, reject))); schedule_flush(); } }) diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 7017b620..b3a858de 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -98,6 +98,11 @@ impl Mirror { self.version } + /// A write of `key` that failed: the next flush writes the key's latest state again (#215). + pub fn retry(&mut self, key: &str) { + self.touch(key); + } + fn touch(&mut self, key: &str) { if !self.dirty.iter().any(|k| k == key) { self.dirty.push(key.to_string()); @@ -443,6 +448,24 @@ mod tests { assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it"); } + /// A write that failed is flushed again with the key's latest state (#215). + #[test] + fn failed_writes_retry() { + let s = store(); + s.put_file("/collision.png", Arc::from(&b"blue"[..]), true); + let mut m = s.lock(); + let p = m.take_pending(); + assert_eq!(p.len(), 1); + assert!(!m.has_pending()); + m.retry(&p[0].key); + assert_eq!(m.take_pending(), p, "the same write again"); + // A newer change wins over the failed one. + m.retry("files/collision.png"); + m.put("files/collision.png", Arc::from(&b"gold"[..]), 2.0, true); + let again = m.take_pending(); + assert_eq!((again.len(), again[0].data.as_deref()), (1, Some(&b"gold"[..]))); + } + #[test] fn keys_and_paths_round_trip() { assert_eq!(file_key("/a b/c.mov"), "files/a b/c.mov"); diff --git a/docs/web.md b/docs/web.md index 12d2b24d..f3b44306 100644 --- a/docs/web.md +++ b/docs/web.md @@ -384,7 +384,7 @@ with the method's `result`, errors reject with the message. | `effectcraft.saveToBrowser(path?)` | save the project to browser storage without downloading it (default: its path, or `/.ecproj`); resolves with `{path, bytes}` | | `effectcraft.listStored()` | `{backend, usage, quota, persisted, pending, files: [{path, size, modified}], config: [name]}` | | `effectcraft.removeStored(path)` | delete a stored file | -| `effectcraft.flush()` | resolves once every change is written to browser storage | +| `effectcraft.flush()` | resolves once every change is written to browser storage; rejects when a write failed (quota exceeded…: the change stays pending and is retried) | | `effectcraft.info()` | graphics backend, `gpu`, `storage`, `audio` (`{state, sampleRate, backend, posted, played, underruns}`), `workers`, `frameWorkers`, `diskCache`, `restored`, `webgpu`, `serviceWorker`, version, `crossOriginIsolated`, load timings | | `effectcraft.workerFrameCheck(params)` | a frame rendered in a frame worker (its GPU by default) against the page's CPU render: `{width, height, maxDiff, meanDiff, over4, workerMs}` |