M13.14: performance section with before/after numbers; test tidy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 12:17:46 -04:00
parent 9904525591
commit 97329edb39
2 changed files with 65 additions and 6 deletions

View File

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

View File

@ -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<Comp>` 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<Project>` 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.