From 97329edb39ab07ab709aa8533dda109b7f11e2d7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 12:17:46 -0400 Subject: [PATCH] M13.14: performance section with before/after numbers; test tidy Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/ui_large_project.rs | 16 ++++--- docs/architecture.md | 55 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/tests/ui_large_project.rs b/crates/ui-egui/tests/ui_large_project.rs index 5fd00dfe..e6ffd26e 100644 --- a/crates/ui-egui/tests/ui_large_project.rs +++ b/crates/ui-egui/tests/ui_large_project.rs @@ -35,12 +35,14 @@ fn project_panel_scrolls_and_reveals_the_selection() { h.run_steps(3); // Far more rows than fit: a scroll bar, and the bottom rows aren't drawn yet. assert!(h.state().auto.find("project.vscroll").is_some()); - let last = h.state().session.project.items.values().filter(|i| i.parent.is_some()).map(|i| i.id.0).max().unwrap(); - let drawn = |h: &Harness<'_, EffectcraftApp>| h.state().auto.previous.iter().filter(|e| e.id.starts_with("project.item.") && e.id.matches('.').count() == 2).count(); + let drawn = |h: &Harness<'_, EffectcraftApp>| { + h.state().auto.previous.iter().filter(|e| e.id.starts_with("project.item.") && e.id.matches('.').count() == 2).count() + }; let rows = drawn(&h); assert!(rows > 5 && rows < 80, "only the rows in view are drawn: {rows}"); // Selecting an item (a command, an agent) scrolls it into view. - let footage = h.state().session.project.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Footage(_))).map(|i| i.id.0).max().unwrap(); + let footage = + h.state().session.project.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Footage(_))).map(|i| i.id.0).max().unwrap(); h.state_mut().session.execute("project.select", json!({"items": [footage]})).unwrap(); h.run_steps(3); assert!(h.state().auto.find(&format!("project.item.{footage}")).is_some(), "selected item scrolled into view"); @@ -50,7 +52,6 @@ fn project_panel_scrolls_and_reveals_the_selection() { h.run_steps(2); let shown: Vec = h.state().auto.previous.iter().filter(|e| e.id.starts_with("project.item.")).map(|e| e.id.clone()).collect(); assert!(!shown.is_empty() && drawn(&h) < 80, "{shown:?}"); - let _ = last; } #[test] @@ -63,7 +64,9 @@ fn comp_thumbnail_renders_off_the_ui_thread() { let mut landed = false; for _ in 0..400 { h.step(); - if h.state().auto.find("project.thumbnail").is_some() && h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new(("proj-thumb", main.0)))).is_some() { + if h.state().auto.find("project.thumbnail").is_some() + && h.ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(egui::Id::new(("proj-thumb", main.0)))).is_some() + { landed = true; break; } @@ -109,7 +112,8 @@ fn lazy_open_lists_the_footage_check_in_the_progress_panel() { std::thread::sleep(std::time::Duration::from_millis(10)); } assert!(h.state().session.tasks.is_empty()); - let missing = h.state().session.project.items.values().filter(|i| matches!(&i.kind, effectcraft_engine::project::ItemKind::Footage(f) if f.missing)).count(); + let missing = + h.state().session.project.items.values().filter(|i| matches!(&i.kind, effectcraft_engine::project::ItemKind::Footage(f) if f.missing)).count(); assert_eq!(missing, spec().footage); assert!(!h.state().session.is_dirty(), "the check doesn't modify the project"); let _ = std::fs::remove_file(path); diff --git a/docs/architecture.md b/docs/architecture.md index ff210e5c..57c22964 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -407,3 +407,58 @@ Web Worker, and the worker's replies (progress, item status, the analysed proper applied on the UI thread. The shortcut dispatcher and the menus read the active preset. See [preferences.md](preferences.md). + +## 7. Performance of everyday operations + +`effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N]` measures the +operations people do all day on a large generated project (`engine::perf::large_project`: 200 +comps, 5,000 layers in the main comp plus 10 in each other comp, 300 footage items — 100 image +sequences of 240 frames, 100 movies, 100 stills — a 20-deep precomp chain, expressions on every +4th layer, Gaussian Blur on every 6th, parenting). The engine side is `engine::perf::ops_bench`; +the UI side (`ui-egui::bench`) drives the real `EffectcraftApp` headless: layout, painting and +tessellation, no window or GPU upload. + +What keeps it fast: + +- **Lazy open.** A project file carries its footage metadata, so File ▸ Open is read + parse + + swap. Nothing is decoded until a frame needs it, expressions compile on first evaluation + (cached by text), and system fonts are scanned only when a text layer asks for a family that + isn't bundled. The footage files are checked afterwards in the background (`footage.check`, a + "Checking footage" job in the Progress panel): missing items are flagged, items saved without + metadata are probed, and the project is not marked modified. The desktop app does this after + every open (`Session::check_footage_on_open`); headless sessions run `footage.check {wait:true}`. +- **No deep copies per frame.** Panels hold `Arc` snapshots (`Session::active_comp_arc`, + `Project::comp_arc`) instead of cloning a comp's layers every frame. +- **Virtualised lists.** The Timeline and the Project panel draw (and register automation ids + for) only the rows in view; the Project panel scrolls vertically and reveals an item selected + elsewhere. Menus listing every layer (track matte, parent) are built only while open. +- **Off the UI thread.** Project panel thumbnails render on a background thread (the previous + thumbnail stays up meanwhile); auto-save snapshots the project (an `Arc` clone) and serialises + and writes compact JSON on a background thread (`AutoSaveState::background`, on in the desktop + app; inline on wasm32). +- **Undo is a pointer swap.** History stores `Arc` snapshots and comps are `Arc`s, so + undo and redo of any edit are constant time. An edit copies the comps it changes (a one-layer + edit in a 5,000-layer comp copies that comp: ~8 ms). + +Numbers (Apple Silicon, 14 cores, release build, best of 3–20 runs; the machine was shared with +other builds, load average 150–220, so treat them as orders of magnitude). "Before" is the +commit before M13.14 with the same benchmark code: + +| Operation | Before | After | +|---|---|---| +| Startup to first full UI frame (open + app + 2 frames) | 2,119 ms | 432 ms (1,482 ms at load 220) | +| File ▸ Open (125 MB pretty JSON; parse is ~95% of it) | 1,049 ms | 269–733 ms | +| Save (pretty JSON, 125 MB) | 845 ms | 275–359 ms | +| Auto-save, UI-thread cost | 530 ms (pretty, synchronous) | 0.1–0.2 ms (compact, background; 69–172 ms off-thread) | +| Steady frame, Standard workspace | 862 ms | 0.5–6.9 ms | +| Timeline frame scrolling 5,000 layers | 1,725 ms | 1.5–8.1 ms | +| Timeline frame, 500 layers twirled open | 1,508 ms | 1.8–5.5 ms | +| Project panel frame, 503 items, all folders open | 1.2 ms (first screenful only, no scrolling) | 0.4–2.8 ms (scrolling) | +| Small edit (one property) / undo + redo | 14.7 ms / 0.01 ms | 8.4–19.7 ms / 0.01 ms | +| Large edit (switch on 5,000 layers) / undo / redo | 62 / 0.03 / 0.01 ms | 18–57 / 0.02 / 0.01 ms | +| First rendered frame of the 5,000-layer comp at ¼ (viewer thread) | 5.7 s | 2.7–3.6 s (unchanged code; load) | + +The before frame times were dominated by an O(layers²) menu build per visible timeline row and +by every panel deep-cloning the active comp each frame. Left for later: per-layer structural +sharing (so a one-layer edit doesn't copy its comp), a binary or compact project format (pretty +JSON parsing is now the bulk of open), and rendering 5,000-layer comps faster.