From 8a4a2bfc5235573433e403a9551cc1d40ef8848d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 11:49:55 -0400 Subject: [PATCH] M13.14: lazy project open, non-blocking UI with large projects, bench --ops - footage.check: background footage check after File > Open (Progress panel), not an undo step - timeline: track-matte / parent menus built only while open (was O(n^2) per frame); expression-row lookup per layer; panels hold Arc snapshots instead of deep clones - Project panel: virtualised vertical scrolling, selection reveal, thumbnails rendered off the UI thread - effectcraft-cli bench --ops (engine::perf + ui-egui::bench) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/effectcraft-cli/Cargo.toml | 1 + apps/effectcraft-cli/src/main.rs | 55 +++++ apps/effectcraft/src/main.rs | 4 + crates/engine/src/commands/file.rs | 5 + crates/engine/src/commands/file_more.rs | 9 + crates/engine/src/footage_check.rs | 192 ++++++++++++++++++ crates/engine/src/lib.rs | 65 ++++++ crates/engine/src/perf.rs | 10 + crates/project/src/lib.rs | 8 + crates/ui-egui/src/bench.rs | 126 ++++++++++++ crates/ui-egui/src/lib.rs | 7 +- .../ui-egui/src/panels/content_fill_panel.rs | 2 +- crates/ui-egui/src/panels/effect_controls.rs | 4 +- crates/ui-egui/src/panels/essential.rs | 2 +- crates/ui-egui/src/panels/info.rs | 2 +- crates/ui-egui/src/panels/layer_panel.rs | 2 +- crates/ui-egui/src/panels/misc.rs | 4 +- crates/ui-egui/src/panels/project.rs | 137 +++++++++---- crates/ui-egui/src/panels/properties.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 54 +++-- crates/ui-egui/src/panels/tracker.rs | 2 +- crates/ui-egui/src/panels/viewer.rs | 6 +- crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/widgets.rs | 5 + crates/ui-egui/tests/ui_large_project.rs | 116 +++++++++++ docs/agents.md | 30 ++- docs/control-protocol.md | 8 +- 28 files changed, 792 insertions(+), 71 deletions(-) create mode 100644 crates/engine/src/footage_check.rs create mode 100644 crates/ui-egui/src/bench.rs create mode 100644 crates/ui-egui/tests/ui_large_project.rs diff --git a/Cargo.lock b/Cargo.lock index 07348727..f7c20c6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1175,6 +1175,7 @@ dependencies = [ "effectcraft-host", "effectcraft-render", "effectcraft-time", + "effectcraft-ui-egui", "image", "serde_json", ] diff --git a/apps/effectcraft-cli/Cargo.toml b/apps/effectcraft-cli/Cargo.toml index ff8842d9..b5784f4e 100644 --- a/apps/effectcraft-cli/Cargo.toml +++ b/apps/effectcraft-cli/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" effectcraft-engine = { workspace = true } effectcraft-host = { workspace = true } effectcraft-automation = { workspace = true } +effectcraft-ui-egui = { workspace = true } effectcraft-render = { workspace = true } effectcraft-gpu = { workspace = true } effectcraft-time = { workspace = true } diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 2e3ff57f..0bb196eb 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -16,6 +16,8 @@ //! effectcraft-cli render F.ecproj --queue render the project's Render Queue //! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] render timings //! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half) +//! effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N] everyday-operation timings +//! on a large generated project (open, save, auto-save, undo/redo, timeline, Project panel) //! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script //! effectcraft-cli mcp [--bridge PORT] MCP server on stdio //! @@ -49,6 +51,9 @@ const USAGE: &str = "usage: effectcraft-cli Result<(), Failure> { /// `bench`: time one frame N times without the layer cache (per-layer and per-effect breakdown), /// and optionally `--play N` consecutive frames with and without the cache. fn bench_cmd(args: &Args) -> Result<(), Failure> { + if args.flag("--ops") { + return bench_ops(args); + } let mut s = effectcraft_host::session(); match &args.project { Some(p) => s.execute("file.open", json!({"path": p})), @@ -637,6 +648,50 @@ fn bench_cmd(args: &Args) -> Result<(), Failure> { Ok(()) } +/// `bench --ops`: everyday operations on a large generated project (200 comps, 5,000 layers in +/// the main comp, 300 footage items, 20-deep precomp nesting, expressions): startup to first +/// frame, open, save, auto-save, small and large edits with undo/redo, first frame, timeline +/// scrolling and the Project panel. `--small` for a quick run; `--layers N`, `--comps N`, +/// `--footage N` resize it. See docs/architecture.md ▸ Performance. +fn bench_ops(args: &Args) -> Result<(), Failure> { + use effectcraft_engine::perf::{self, LargeSpec}; + let mut spec = if args.flag("--small") { LargeSpec::small() } else { LargeSpec::default() }; + if let Some(n) = args.num("--layers")? { + spec.main_layers = n.max(1.0) as usize; + } + if let Some(n) = args.num("--comps")? { + spec.comps = n.max(1.0) as usize; + } + if let Some(n) = args.num("--footage")? { + spec.footage = n.max(0.0) as usize; + } + let dir = std::env::temp_dir().join(format!("effectcraft-bench-ops-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|e| Failure::Error(format!("{}: {e}", dir.display())))?; + let t0 = std::time::Instant::now(); + drop(effectcraft_host::session()); + let host_ms = t0.elapsed().as_secs_f64() * 1000.0; + let (mut m, _) = perf::ops_bench(&spec, &dir, &effectcraft_host::session); + m.insert(0, perf::Measure { name: "hostSession", ms: host_ms, note: "a fully wired session (media, expressions, scripting, export)".into() }); + let path = dir.join("bench-large.ecproj").to_string_lossy().to_string(); + m.extend(effectcraft_ui_egui::bench::ui_ops(|| { + let mut s = effectcraft_host::session(); + if let Err(e) = s.execute("file.open", json!({"path": path})) { + eprintln!("bench --ops: {e}"); + } + s + })); + let _ = std::fs::remove_dir_all(&dir); + if args.flag("--json") { + emit(&json!({"spec": format!("{spec:?}"), "measures": m.iter().map(perf::Measure::json).collect::>()}), true); + } else { + println!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage); + for x in &m { + println!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note); + } + } + Ok(()) +} + // ---------------------------------------------------------------- benchmark helpers use effectcraft_engine::Session; diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index bef701cb..2262ad80 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -63,6 +63,10 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut session = effectcraft_host::session(); + // Lazy open and non-blocking auto-save: footage is checked and auto-saves are + // written on background threads (M13.14). + session.check_footage_on_open = true; + session.autosave.background = true; // Settings, shortcut presets and the crash-recovery sentinel live in the platform // config directory. Agent-driven runs (`--control`) skip crash recovery. if let Some(dir) = config_dir() { diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 14cbb758..1cd8f95c 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -45,6 +45,11 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let proj = Project::from_json(&text)?; s.replace_project(proj, Some(path.to_string())); s.note_project_path(path); + // Lazy open: footage is checked in the background (Progress panel), not before the + // project shows. + if s.check_footage_on_open { + crate::footage_check::start(s, None, false)?; + } Ok(json!({"path": path})) } diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 2041d400..af304e73 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -823,6 +823,15 @@ pub fn specs() -> Vec { reduce ), cmd!("file.findMissing", "Find Missing", [], None, "{what: footage|effects|fonts}", has_items, find_missing), + cmd!( + "footage.check", + "Check Footage", + [], + None, + "{items?: [id], wait?} — look for every footage file (in the background unless `wait`) and flag missing items", + has_items, + crate::footage_check::command + ), cmd!( "file.runScript", "Run Script File...", diff --git a/crates/engine/src/footage_check.rs b/crates/engine/src/footage_check.rs new file mode 100644 index 00000000..79fee8d7 --- /dev/null +++ b/crates/engine/src/footage_check.rs @@ -0,0 +1,192 @@ +//! Lazy project open: footage is checked in the background after File ▸ Open instead of while +//! the project loads (M13.14). +//! +//! A project file already carries every footage item's metadata, so opening it never touches +//! the media: decoding starts when a frame is first needed, thumbnails when the Project panel +//! shows one, expressions compile on first evaluation and system fonts are scanned only when a +//! text layer asks for a family that isn't bundled. What open used to skip entirely is noticing +//! that files went missing (or that an item was saved without metadata). `footage.check` does +//! that as a background task listed in the Progress panel ("Checking footage"): it looks for +//! every file (each frame of an image sequence), probes items that have no metadata, and then +//! flags missing items (`missing`, File ▸ Dependencies ▸ Find Missing Footage). The desktop app +//! starts it after every open ([`Session::check_footage_on_open`]); agents and the CLI run it +//! with `wait: true`. + +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind}; +use serde_json::{Value, json}; + +use crate::Session; + +/// One item to check. +struct Job { + id: ItemId, + footage: Footage, +} + +/// What the check found for one item. +enum Found { + Missing(bool), + Probed(Box), +} + +fn exists(s: &dyn crate::Services, path: &str) -> bool { + !path.is_empty() && s.exists(path) +} + +/// Start the check (all footage, or `items`). `wait` runs it to completion first (always on +/// wasm32); the result is then `{checked, missing, probed}`. +pub fn start(s: &mut Session, items: Option>, wait: bool) -> crate::Result { + let jobs: Vec = s + .project + .items + .values() + .filter(|i| items.as_ref().is_none_or(|v| v.contains(&i.id))) + .filter_map(|i| match &i.kind { + ItemKind::Footage(f) if f.kind != FootageKind::Data => Some(Job { id: i.id, footage: f.clone() }), + _ => None, + }) + .collect(); + if jobs.is_empty() { + return Ok(json!({"checked": 0, "missing": 0, "probed": 0})); + } + let services = s.services.clone(); + let importer = s.importer.clone(); + let n = jobs.len(); + s.spawn_task("footageCheck", format!("Checking footage ({n} items)"), wait, move |ctl| { + let mut found: Vec<(ItemId, Found)> = Vec::with_capacity(jobs.len()); + let total = jobs.len() as u64; + for (k, j) in jobs.into_iter().enumerate() { + if !ctl.progress(k as u64, total) { + return Err(crate::render_queue::CANCELLED.into()); + } + let f = &j.footage; + let present = if f.sequence.is_empty() { + exists(services.as_ref(), &f.path) + } else { + // A sequence is there while any of its frames is (missing frames render as + // placeholders, as in After Effects). + exists(services.as_ref(), &f.path) || f.sequence.iter().any(|p| exists(services.as_ref(), p)) + }; + // Saved without metadata (a hand-written or converted project): probe it now. + let needs_probe = present && f.width == 0 && f.height == 0 && f.kind != FootageKind::Audio; + match importer.as_ref().filter(|_| needs_probe).map(|imp| imp.probe(&f.path)) { + Some(Ok(mut nf)) => { + nf.alpha = f.alpha; + nf.loop_count = f.loop_count; + found.push((j.id, Found::Probed(Box::new(nf)))); + } + _ => found.push((j.id, Found::Missing(!present))), + } + } + ctl.progress(total, total); + let apply: crate::jobs::Apply = Box::new(move |s: &mut Session| Ok(apply(s, found))); + Ok(apply) + }) +} + +/// Write the findings into the project. Not an undo step: it records the state of the disk, +/// not an edit (the same as After Effects marking footage missing at open). +fn apply(s: &mut Session, found: Vec<(ItemId, Found)>) -> Value { + let checked = found.len(); + let (mut missing, mut probed, mut changed) = (0, 0, false); + let p = std::sync::Arc::make_mut(&mut s.project); + for (id, f) in found { + let Some(ItemKind::Footage(cur)) = p.item_mut(id).map(|i| &mut i.kind) else { continue }; + match f { + Found::Missing(m) => { + missing += usize::from(m); + if cur.missing != m { + cur.missing = m; + changed = true; + } + } + Found::Probed(nf) => { + probed += 1; + *cur = *nf; + changed = true; + } + } + } + if changed { + let dirty = s.is_dirty(); + s.bump(); + if !dirty { + // Only the disk changed: the project isn't modified. + s.saved_revision = s.revision; + } + s.events.push(crate::Event::PurgeCaches); + } + if missing > 0 { + s.toast(format!("{missing} footage item(s) missing (File ▸ Dependencies ▸ Find Missing Footage)")); + } + json!({"checked": checked, "missing": missing, "probed": probed}) +} + +pub(crate) fn command(s: &mut Session, p: &Value) -> crate::Result { + let items = p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(ItemId).collect()); + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + start(s, items, wait) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session_with(paths: &[&str]) -> Session { + let mut s = Session::default(); + for (i, p) in paths.iter().enumerate() { + let f = Footage { path: p.to_string(), width: 64, height: 48, has_video: true, ..Default::default() }; + std::sync::Arc::make_mut(&mut s.project).add_item(&format!("f{i}"), crate::color::Label::None, None, ItemKind::Footage(f)); + } + s + } + + #[test] + fn check_flags_missing_footage_without_an_undo_step_or_dirtying() { + let dir = std::env::temp_dir().join(format!("ec-footage-check-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let there = dir.join("there.png"); + std::fs::write(&there, b"x").unwrap(); + let mut s = session_with(&[there.to_str().unwrap(), "/nonexistent/ec/gone.png"]); + s.saved_revision = s.revision; + let r = s.execute("footage.check", json!({"wait": true})).unwrap(); + assert_eq!(r, json!({"checked": 2, "missing": 1, "probed": 0})); + let missing: Vec = s + .project + .items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Footage(f) => Some(f.missing), + _ => None, + }) + .collect(); + assert_eq!(missing, [false, true]); + assert!(s.history.undo.is_empty() && !s.is_dirty()); + // In the background: listed as a job until polled. + std::fs::remove_file(&there).unwrap(); + let r = s.execute("footage.check", json!({})).unwrap(); + assert_eq!(r["started"], true); + assert!(s.jobs().iter().any(|j| j.kind == "footageCheck")); + let t0 = std::time::Instant::now(); + while !s.tasks.is_empty() && t0.elapsed().as_secs() < 10 { + s.poll_jobs(); + std::thread::yield_now(); + } + assert!(s.project.items.values().all(|i| matches!(&i.kind, ItemKind::Footage(f) if f.missing))); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn open_starts_the_check_when_asked() { + let mut s = session_with(&["/nonexistent/ec/a.mov"]); + let path = std::env::temp_dir().join(format!("ec-footage-open-{}.ecproj", std::process::id())); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut o = Session { check_footage_on_open: true, ..Default::default() }; + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert!(o.jobs().iter().any(|j| j.kind == "footageCheck"), "{:?}", o.jobs()); + let mut plain = Session::default(); + plain.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert!(plain.jobs().is_empty()); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e44318d2..4fc5bc1a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod camera_track; pub mod commands; pub mod config; pub mod demo; +pub mod footage_check; pub mod history; pub mod jobs; pub mod learn; @@ -85,6 +86,10 @@ pub type Result = std::result::Result; pub trait Services: Send + Sync { fn read_file(&self, path: &str) -> std::io::Result>; fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()>; + /// Whether a file exists (the background footage check after open). + fn exists(&self, path: &str) -> bool { + std::path::Path::new(path).exists() + } } /// Native filesystem. @@ -367,8 +372,15 @@ pub struct Session { pub next_task_id: u64, /// The running Learn tutorial (Home ▸ Learn), see [`learn`]. pub learn: Option, + /// Check footage in the background after File ▸ Open ([`footage_check`]): frontends with an + /// event loop (the desktop app) turn this on; headless sessions run `footage.check` when + /// they want it. + pub check_footage_on_open: bool, } +/// A thumbnail render ([`Session::thumbnail_job`]): (width, height, RGBA8). +pub type ThumbnailJob = Box Option<(u32, u32, Vec)> + Send>; + /// A script to run (see [`Session::script`]). #[derive(Clone, Copy, Debug)] pub struct ScriptRequest<'a> { @@ -438,6 +450,7 @@ impl Default for Session { job_log: vec![], next_task_id: 1, learn: None, + check_footage_on_open: false, } } } @@ -582,6 +595,10 @@ impl Session { pub fn active_comp(&self) -> Option<&Comp> { self.project.comp(self.state.active_comp?) } + /// [`Session::active_comp`] as a shared snapshot (an `Arc` clone, not a deep copy). + pub fn active_comp_arc(&self) -> Option> { + self.project.comp_arc(self.state.active_comp?) + } pub fn active_comp_id(&self) -> Option { self.state.active_comp.filter(|c| self.project.comp(*c).is_some()) } @@ -713,6 +730,54 @@ impl Session { Ok((img.width, img.height, img.to_rgba8_over(c.background))) } + /// A Project panel thumbnail of an item (a comp at `t`, footage at its first frame), longest + /// side at most `max_side`, as a job that owns everything it reads: frontends run it off the + /// UI thread (a big comp takes far longer than a frame). `None` for items without pixels. + pub fn thumbnail_job(&self, item: ItemId, t: Tick, max_side: u32) -> Option { + let it = self.project.item(item)?; + let footage = self.footage.clone(); + match &it.kind { + effectcraft_project::ItemKind::Comp(c) => { + let (project, expr, cache) = (self.project.clone(), self.expr.clone(), self.layer_cache.clone()); + let long = c.width.max(c.height).max(1) as f64; + let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; + let opts = RenderOpts { + scale, + nested_switches: self.prefs.general.switches_affect_nested_comps, + draft_shadows: self.prefs.three_d.realtime_shadows, + ..Default::default() + }; + let bg = c.background; + Some(Box::new(move || { + let mut r = Renderer::new(&project, footage.as_ref(), opts); + r.expr = expr.as_deref(); + r.cache = Some(&cache); + let img = r.comp_frame(item, t); + Some((img.width, img.height, img.to_rgba8_over(bg))) + })) + } + effectcraft_project::ItemKind::Footage(f) if f.has_video => { + let f = f.clone(); + Some(Box::new(move || { + let img = footage.frame(item, &f, Tick(0))?; + // Point-sample down to at most `max_side` on the long side. + let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; + let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); + let full = img.to_rgba8(); + let mut px = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let i = (((y * step) * img.width + x * step) * 4) as usize; + px.extend_from_slice(&full[i..i + 4]); + } + } + Some((w, h, px)) + })) + } + _ => None, + } + } + /// Replace the whole project (open/new), resetting history and state. pub fn replace_project(&mut self, mut p: Project, path: Option) { if self.stop_render() diff --git a/crates/engine/src/perf.rs b/crates/engine/src/perf.rs index e429491e..f6f37b98 100644 --- a/crates/engine/src/perf.rs +++ b/crates/engine/src/perf.rs @@ -283,6 +283,16 @@ pub fn ops_bench(spec: &LargeSpec, dir: &std::path::Path, fresh: &dyn Fn() -> Se s }); out.push(Measure { name: "open", ms, note: format!("active comp {:?}", s.state.active_comp.map(|c| c.0)) }); + // Its parts: reading the file and parsing it (the rest is replacing the session's project). + let (ms, text) = time_best(3, || std::fs::read_to_string(&path).unwrap_or_default()); + out.push(Measure { name: "openRead", ms, note: format!("{} MB", text.len() / 1_000_000) }); + let (ms, _) = time_best(3, || Project::from_json(&text).expect("parse")); + out.push(Measure { name: "openParse", ms, note: "JSON → Project".into() }); + let compact = Project::from_json(&text).expect("parse").to_json_compact(); + drop(text); + let (ms, _) = time_best(3, || Project::from_json(&compact).expect("parse")); + out.push(Measure { name: "openParseCompact", ms, note: format!("the same as compact JSON ({} MB, as auto-saves are written)", compact.len() / 1_000_000) }); + drop(compact); // First frame of the main comp at 1/4 resolution (the viewer's first request). let cid = s.active_comp_id().expect("main comp"); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index da85a844..5e32c7a4 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -987,6 +987,14 @@ impl Project { pub fn comp(&self, id: ItemId) -> Option<&Comp> { self.items.get(&id)?.as_comp() } + /// A shared handle on a comp: a snapshot that costs a reference count, not a deep copy of + /// its layers (frontends hold one across a frame instead of cloning the comp). + pub fn comp_arc(&self, id: ItemId) -> Option> { + match &self.items.get(&id)?.kind { + ItemKind::Comp(c) => Some(c.clone()), + _ => None, + } + } /// Mutable comp (copy-on-write). pub fn comp_mut(&mut self, id: ItemId) -> Option<&mut Comp> { match &mut self.items.get_mut(&id)?.kind { diff --git a/crates/ui-egui/src/bench.rs b/crates/ui-egui/src/bench.rs new file mode 100644 index 00000000..567588de --- /dev/null +++ b/crates/ui-egui/src/bench.rs @@ -0,0 +1,126 @@ +//! UI-side everyday-operation timings for `effectcraft-cli bench --ops` (see +//! `effectcraft_engine::perf` for the engine side): the app's first frame, and steady frames of +//! the Timeline (scrolling a 5,000-layer comp) and the Project panel (300+ items), measured +//! headless — layout, painting and tessellation, without a window or GPU upload. + +use effectcraft_engine::Session; +use effectcraft_engine::perf::Measure; + +use crate::EffectcraftApp; +use crate::dock::PanelKind; + +/// A headless egui driver for [`EffectcraftApp`]. +pub struct Driver { + pub ctx: egui::Context, + pub app: EffectcraftApp, + frame: eframe::Frame, + time: f64, + size: egui::Vec2, +} + +impl Driver { + pub fn new(app: EffectcraftApp, size: egui::Vec2) -> Driver { + Driver { ctx: egui::Context::default(), app, frame: eframe::Frame::_new_kittest(), time: 0.0, size } + } + + /// Run one frame (layout + paint + tessellation); its wall time in ms. + pub fn step(&mut self) -> f64 { + self.step_with(vec![]) + } + + pub fn step_with(&mut self, events: Vec) -> f64 { + self.time += 1.0 / 60.0; + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, self.size)), time: Some(self.time), events, ..Default::default() }; + let t0 = web_time::Instant::now(); + let (app, frame) = (&mut self.app, &mut self.frame); + let mut out = self.ctx.run_ui(input, |ui| { + eframe::App::logic(app, ui.ctx(), frame); + eframe::App::ui(app, ui, frame); + }); + // No GPU here: texture uploads are dropped (counted in the frame time up to here). + out.textures_delta.clear(); + let _ = self.ctx.tessellate(out.shapes, out.pixels_per_point); + t0.elapsed().as_secs_f64() * 1000.0 + } + + /// Mean and max of `n` frames, calling `before` ahead of each. + pub fn frames(&mut self, n: usize, mut before: impl FnMut(&mut EffectcraftApp, usize)) -> (f64, f64) { + let (mut sum, mut max) = (0.0, 0.0f64); + for i in 0..n { + before(&mut self.app, i); + let ms = self.step(); + sum += ms; + max = max.max(ms); + } + (sum / n.max(1) as f64, max) + } +} + +/// The UI timings for a session holding the large project (`perf::large_project`), opened by +/// `open` (whose time counts as part of startup). +pub fn ui_ops(open: impl FnOnce() -> Session) -> Vec { + let mut out = vec![]; + let t0 = web_time::Instant::now(); + let session = open(); + let opened = t0.elapsed().as_secs_f64() * 1000.0; + let t1 = web_time::Instant::now(); + let app = EffectcraftApp::new(session); + let built = t1.elapsed().as_secs_f64() * 1000.0; + let mut d = Driver::new(app, egui::vec2(1680.0, 1020.0)); + // The first pass installs the theme and fonts; the second draws the workspace. + let f1 = d.step(); + let f2 = d.step(); + out.push(Measure { + name: "startupToFirstFrame", + ms: opened + built + f1 + f2, + note: format!("open {opened:.1} + app {built:.1} + frames {f1:.1}/{f2:.1} ms"), + }); + let (mean, max) = d.frames(10, |_, _| {}); + out.push(Measure { name: "workspaceFrame", ms: mean, note: format!("Standard workspace, steady (max {max:.1} ms)") }); + + // Timeline maximized, scrolling through every layer of the main comp. + let layers = d.app.session.active_comp().map_or(0, |c| c.layers.len()); + d.app.show_panel(PanelKind::Timeline); + d.app.ui.maximized = Some(PanelKind::Timeline); + d.step(); + let step = (layers as f32 * 20.0 / 60.0).max(40.0); + let (mean, max) = d.frames(60, |app, i| app.ui.timeline.scroll_y = i as f32 * step); + out.push(Measure { name: "timelineScrollFrame", ms: mean, note: format!("{layers} layers, 60 frames scrolling to the end (max {max:.1} ms)") }); + // Every 10th layer twirled open (property rows). + let open: Vec = d.app.session.active_comp().map(|c| c.layers.iter().step_by(10).map(|l| l.id.0).collect()).unwrap_or_default(); + d.app.ui.timeline.open_layers.extend(open.iter().copied()); + let (mean, max) = d.frames(30, |app, i| app.ui.timeline.scroll_y = i as f32 * step * 2.0); + out.push(Measure { name: "timelineOpenFrame", ms: mean, note: format!("{} layers twirled open (max {max:.1} ms)", open.len()) }); + d.app.ui.timeline.open_layers.clear(); + d.app.ui.timeline.scroll_y = 0.0; + + // Project panel maximized with every folder open. + let items = d.app.session.project.items.len(); + let folders: Vec = d.app.session.project.items.values().filter(|i| i.is_folder()).map(|i| i.id.0).collect(); + d.app.ui.project_open_folders.extend(folders); + d.app.show_panel(PanelKind::Project); + d.app.ui.maximized = Some(PanelKind::Project); + d.step(); + let (mean, max) = d.frames(30, |app, i| app.ui.project_scroll = i as f32 * 400.0); + out.push(Measure { name: "projectPanelFrame", ms: mean, note: format!("{items} items, folders open, scrolling (max {max:.1} ms)") }); + d.app.ui.maximized = None; + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_ops_runs_on_a_small_project() { + let m = ui_ops(|| { + let mut s = Session::default(); + s.replace_project(effectcraft_engine::perf::large_project(&effectcraft_engine::perf::LargeSpec::small()), None); + s + }); + let names: Vec<&str> = m.iter().map(|m| m.name).collect(); + assert_eq!(names, ["startupToFirstFrame", "workspaceFrame", "timelineScrollFrame", "timelineOpenFrame", "projectPanelFrame"]); + assert!(m.iter().all(|m| m.ms.is_finite() && m.ms > 0.0), "{m:?}"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1380553a..c9fd5707 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,6 +7,7 @@ pub mod audio; pub mod automation; +pub mod bench; pub mod control; pub mod dock; pub mod dock_ui; @@ -547,7 +548,7 @@ impl EffectcraftApp { /// Start the preview of a Preview panel shortcut (its range, play-from, rate, skip, /// resolution, loop, caching, full screen and include options). pub fn play_with(&mut self, now: f64, sc: effectcraft_engine::preview::PreviewShortcut) { - let Some(c) = self.session.active_comp().cloned() else { return }; + let Some(c) = self.session.active_comp_arc() else { return }; let preset = self.session.prefs.preview.get(sc).clone(); let plan = effectcraft_engine::preview::plan(&preset, &c, self.session.time()); let fr = c.frame_rate; @@ -590,7 +591,7 @@ impl EffectcraftApp { return; } let Some(cid) = self.session.active_comp_id() else { return }; - let Some(c) = self.session.project.comp(cid).cloned() else { return }; + let Some(c) = self.session.project.comp_arc(cid) else { return }; // A preview at another frame rate plays slower or faster than real time: silent. if (pl.fps - c.frame_rate.as_f64()).abs() > 0.01 { return; @@ -687,7 +688,7 @@ impl EffectcraftApp { /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). fn advance_playback(&mut self, ctx: &egui::Context, scale: f64) { let Some(cid) = self.session.active_comp_id() else { return }; - let Some(c) = self.session.project.comp(cid).cloned() else { return }; + let Some(c) = self.session.project.comp_arc(cid) else { return }; let now = ctx.input(|i| i.time); let fr = c.frame_rate; let plan = self.playback.plan.filter(|_| self.playback.playing); diff --git a/crates/ui-egui/src/panels/content_fill_panel.rs b/crates/ui-egui/src/panels/content_fill_panel.rs index 1937b4cc..4b87d678 100644 --- a/crates/ui-egui/src/panels/content_fill_panel.rs +++ b/crates/ui-egui/src/panels/content_fill_panel.rs @@ -22,7 +22,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let w = (rect.max.x - xv - 12.0).clamp(90.0, 220.0); let mut y = rect.min.y + 10.0; let st = app.session.state.content_fill.clone(); - let comp = app.session.active_comp().cloned(); + let comp = app.session.active_comp_arc(); let layer = comp.as_ref().and_then(|c| app.session.state.selected_layers.first().and_then(|l| c.layer(*l)).cloned()); // Fill Target. p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, "Fill Target", Tokens::semibold(12.0), t.text); diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index b5160410..a5b854bd 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -317,7 +317,7 @@ fn prop_row( } } Value::Layer(l) => { - let comp = app.session.active_comp().cloned(); + let comp = app.session.active_comp_arc(); if let Some(comp) = comp { let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0)); let idx_of = |id: u64| comp.layers.iter().position(|x| x.id.0 == id); @@ -883,7 +883,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { return; }; let Some(cid) = app.session.active_comp_id() else { return }; - let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default())); + let comp = app.session.project.comp_arc(cid).unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default()).into()); let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index b22263b9..437181a3 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -395,7 +395,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt /// Revert and Push to Comp. fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions) { let t = app.tokens; - let Some(comp) = app.session.active_comp().cloned() else { return }; + let Some(comp) = app.session.active_comp_arc() else { return }; let Some(layer) = app.session.state.selected_layers.iter().filter_map(|l| comp.layer(*l)).find(|l| essential::group(l).is_some()).cloned() else { return }; let LayerSource::Comp { item: src } = layer.source else { return }; let Some(eg) = app.session.project.comp(src).and_then(|c| c.essential.clone()) else { return }; diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index aeaeb355..9044047e 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -60,7 +60,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += 82.0; p.line_segment([pos2(rect.min.x + 8.0, y), pos2(rect.max.x - 8.0, y)], egui::Stroke::new(1.0, t.separator)); y += 14.0; - let comp = app.session.active_comp().cloned(); + let comp = app.session.active_comp_arc(); if let Some(c) = comp { let sel: Vec = app.session.state.selected_layers.iter().filter_map(|id| c.layer(*id)).map(|l| l.name.clone()).collect(); let tc = crate::panels::timecode(&app.session, &c, app.session.time()); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 20ccf358..0e45bf32 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -71,7 +71,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("layerPanel.empty", rect, "Layer panel (empty)"); return; }; - let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default())); + let comp = app.session.project.comp_arc(cid).unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default()).into()); let time = app.session.time(); let fx_count = layer.effects().map(|f| f.groups().count()).unwrap_or(0); let view = app.ui.layer_view.map(|v| v.min(fx_count)).unwrap_or_else(|| default_view(&layer, app.ui.tool)); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 6c9bac94..6ff3e95e 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -215,7 +215,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Cache bar over the preview range. - if let Some(c) = app.session.active_comp().cloned() { + if let Some(c) = app.session.active_comp_arc() { let cid = app.session.active_comp_id().map(|i| i.0).unwrap_or(0); let scale = app.viewer_shown.as_ref().map(|(_, k)| k.scale).unwrap_or(1000); let pl = match app.playback.plan.filter(|_| app.playback.playing) { @@ -431,7 +431,7 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); - let Some(c) = app.session.active_comp().cloned() else { return }; + let Some(c) = app.session.active_comp_arc() else { return }; let mut y = rect.min.y + 8.0; if c.markers.is_empty() { p.text(rect.center(), Align2::CENTER_CENTER, "No composition markers", Tokens::ui(12.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 8c89f25f..d78a1055 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -5,6 +5,8 @@ //! the label colour picker, and the bottom bar (interpret, new folder, new comp, bit depth, //! delete). Edits are `project.*` engine commands, so they are undoable. +use std::sync::{Arc, Mutex}; + use effectcraft_engine::project::{Item, ItemId, ItemKind}; use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; @@ -102,41 +104,69 @@ pub fn drop_folder(project: &effectcraft_engine::project::Project, row: Option)>>>>); + /// Thumbnail of a comp (its current time) or footage (first frame), cached per item/revision. +/// Rendered on a background thread (inline on wasm32): until the new one lands the previous +/// thumbnail stays up, so selecting a big comp or editing it never stalls the panel. fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, it: &Item) -> Option { let key = egui::Id::new(("proj-thumb", it.id.0)); + let pkey = egui::Id::new(("proj-thumb-pending", it.id.0)); let rev = app.session.revision; - if let Some((r, tex)) = ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(key)) - && r == rev - { - return Some(tex); - } - let (w, h, px) = match &it.kind { - ItemKind::Comp(_) => { - let t = app.session.state.times.get(&it.id).copied().unwrap_or_default(); - app.session.render_rgba8(it.id, t, 192).ok()? - } - ItemKind::Footage(f) if f.has_video => { - let img = app.session.footage.frame(it.id, f, effectcraft_engine::time::Tick(0))?; - // Point-sample down to at most 192 px on the long side. - let step = (img.width.max(img.height) as f32 / 192.0).ceil().max(1.0) as u32; - let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); - let full = img.to_rgba8(); - let mut px = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - let i = (((y * step) * img.width + x * step) * 4) as usize; - px.extend_from_slice(&full[i..i + 4]); + let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(key)); + let mut tex = cached.as_ref().map(|(_, t)| t.clone()); + let have = cached.as_ref().map(|(r, _)| *r); + // A finished render: upload it. + let pending: Option = ctx.data(|d| d.get_temp(pkey)); + if let Some((prev, slot)) = &pending { + let done = slot.lock().ok().and_then(|mut s| s.take()); + match done { + Some(Some((w, h, px))) => { + let ci = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &px); + let t = ctx.load_texture(format!("proj-thumb-{}", it.id.0), ci, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| { + d.insert_temp(key, (*prev, t.clone())); + d.remove::(pkey); + }); + tex = Some(t); + if *prev == rev { + return tex; } } - (w, h, px) + Some(None) => { + ctx.data_mut(|d| d.remove::(pkey)); + return tex; + } + None => { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + return tex; + } } - _ => return None, - }; - let ci = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &px); - let tex = ctx.load_texture(format!("proj-thumb-{}", it.id.0), ci, egui::TextureOptions::LINEAR); - ctx.data_mut(|d| d.insert_temp(key, (rev, tex.clone()))); - Some(tex) + } + if have == Some(rev) { + return tex; + } + let t = app.session.state.times.get(&it.id).copied().unwrap_or_default(); + let job = app.session.thumbnail_job(it.id, t, 192)?; + let slot: Arc)>>>> = Arc::default(); + if cfg!(target_arch = "wasm32") { + *slot.lock().ok()? = Some(job()); + } else { + let s = slot.clone(); + let spawned = std::thread::Builder::new().name("proj-thumb".into()).spawn(move || { + let r = job(); + if let Ok(mut s) = s.lock() { + *s = Some(r); + } + }); + if spawned.is_err() { + return tex; + } + } + ctx.data_mut(|d| d.insert_temp::(pkey, (rev, slot))); + ctx.request_repaint(); + tex } fn fit(r: Rect, w: f32, h: f32) -> Rect { @@ -290,7 +320,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.project_hscroll = (hscroll + sresp.drag_delta().x * overflow / (track.width() - tw).max(1.0)).clamp(0.0, overflow); } } - let mut y = list.min.y; let query = app.ui.project_search.to_lowercase(); let mut rows: Vec<(ItemId, usize)> = vec![]; fn walk(app: &EffectcraftApp, folder: Option, depth: usize, q: &str, out: &mut Vec<(ItemId, usize)>) { @@ -324,8 +353,48 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y); let mut drop_row: Option = None; let mut actions: Vec<(String, serde_json::Value)> = vec![]; - for (i, (id, depth)) in rows.iter().enumerate() { - let Some(it) = app.session.project.item(*id).cloned() else { continue }; + // Vertical scrolling: only the rows in view are drawn (and registered), so a project with + // thousands of items costs what one screenful does. + let content_h = rows.len() as f32 * ROW_H; + let max_scroll = (content_h - list.height() + ROW_H).max(0.0); + if ui.rect_contains_pointer(list) { + let (dy, shift) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.shift)); + if !shift && dy != 0.0 { + app.ui.project_scroll -= dy; + } + } + // A selection made elsewhere (a command, an agent, the viewer) scrolls into view. + let sel_key = egui::Id::new("proj-sel-seen"); + let sel_now = app.session.state.project_selection.last().map(|i| i.0); + if ctx.data(|d| d.get_temp::>(sel_key)) != Some(sel_now) { + ctx.data_mut(|d| d.insert_temp(sel_key, sel_now)); + if let Some(row) = sel_now.and_then(|s| rows.iter().position(|(id, _)| id.0 == s)) { + let top = row as f32 * ROW_H; + if top < app.ui.project_scroll { + app.ui.project_scroll = top; + } else if top + ROW_H > app.ui.project_scroll + list.height() { + app.ui.project_scroll = top + ROW_H - list.height(); + } + } + } + app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll); + let scroll = app.ui.project_scroll; + if max_scroll > 0.0 { + let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0)); + let th = (track.height() * list.height() / content_h).clamp(16.0, track.height()); + let thumb = Rect::from_min_size(pos2(track.min.x, track.min.y + (track.height() - th) * (scroll / max_scroll)), vec2(track.width(), th)); + let vresp = ui.interact(track.expand2(vec2(2.0, 0.0)), egui::Id::new("proj-vscroll"), Sense::drag()); + p.rect_filled(thumb, 2.0, if vresp.hovered() || vresp.dragged() { t.text_dim } else { t.text_faint }); + app.auto.add("project.vscroll", track, &format!("{scroll}/{max_scroll}")); + if vresp.dragged() { + app.ui.project_scroll = (scroll + vresp.drag_delta().y * max_scroll / (track.height() - th).max(1.0)).clamp(0.0, max_scroll); + } + } + let first = ((scroll / ROW_H).floor() as usize).min(rows.len()); + let mut y = list.min.y - (scroll - first as f32 * ROW_H); + let project = app.session.project.clone(); + for (i, (id, depth)) in rows.iter().enumerate().skip(first) { + let Some(it) = project.item(*id) else { continue }; let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H)); y += ROW_H; if r.min.y > list.max.y { @@ -355,7 +424,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name); } - icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(&it), t.text_dim); + icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim); // Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch. if let Some(px) = it.proxy.as_ref() { let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0)); @@ -437,10 +506,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cp = lp.with_clip_rect(cell); let txt = match *key { "type" => it.type_name().to_string(), - "size" => file_size(&it).map(fmt_size).unwrap_or_default(), + "size" => file_size(it).map(fmt_size).unwrap_or_default(), "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(), "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(), - "path" => file_path(&it).to_string(), + "path" => file_path(it).to_string(), "comment" => { let cr = Rect::from_min_size(pos2(*cx - 2.0, r.min.y + 2.0), vec2(*w, r.height() - 4.0)); if cell_edit(app, ui, &mut actions, "comment", cr) { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c5795685..09f9eda3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -60,7 +60,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); let ctx = ui.ctx().clone(); - let comp = app.session.active_comp().cloned(); + let comp = app.session.active_comp_arc(); let layer = comp.as_ref().and_then(|c| app.session.state.selected_layers.first().and_then(|id| c.layer(*id)).cloned()); let (Some(comp), Some(cid), Some(layer)) = (comp, app.session.active_comp_id(), layer) else { p.text(rect.center(), Align2::CENTER_CENTER, "Select a layer to see its properties", Tokens::ui(12.0), t.text_faint); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 06afd2b9..8f75dfa4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -75,15 +75,23 @@ fn row_height(row: &Row, rh: f32) -> f32 { /// Insert an expression editor row after every property that has an expression. fn with_expr_rows(rows: Vec, comp: &Comp, closed: &std::collections::BTreeSet) -> Vec { let mut out = Vec::with_capacity(rows.len()); + // Rows come grouped by layer: look each layer up once (a linear search per property row + // made twirling open many layers of a big comp quadratic). + let mut cur: Option<&Layer> = None; for r in rows { - let extra = match r.kind { - RowKind::Prop { uid } if !closed.contains(&uid) => comp.layer(r.layer).and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row { + let mut extra = None; + if let RowKind::Prop { uid } = r.kind + && !closed.contains(&uid) + { + if cur.is_none_or(|l| l.id != r.layer) { + cur = comp.layer(r.layer); + } + extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row { layer: r.layer, depth: r.depth, kind: RowKind::Expr { uid, lines: e.text.lines().count().max(1) }, - }), - _ => None, - }; + }); + } out.push(r); out.extend(extra); } @@ -336,7 +344,7 @@ fn tl_map_id() -> egui::Id { /// Zoom the time ruler around the CTI. pub fn zoom(app: &mut EffectcraftApp, ctx: &egui::Context, k: f64) { let Some((x0, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; - let comp = app.session.active_comp().cloned(); + let comp = app.session.active_comp_arc(); let Some(comp) = comp else { return }; let fit = (w as f64 - 20.0) / comp.duration.seconds().max(0.01); let pps = app.ui.timeline.pps.unwrap_or(fit); @@ -731,7 +739,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); return; }; - let Some(comp) = app.session.project.comp(cid).cloned() else { return }; + let Some(comp) = app.session.project.comp_arc(cid) else { return }; let p = ui.painter().clone(); let time = app.session.time(); let fr = comp.frame_rate; @@ -1294,14 +1302,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if tresp.clicked() { widgets::open_popup(ui, pop); } + // The menu lists every layer: built only while it is open (per visible row + // it made a big comp's timeline quadratic). let mut opts = vec!["No Track Matte".to_string(), "-".to_string()]; - let candidates: Vec<&Layer> = comp.layers.iter().filter(|l| l.id != layer.id && l.source.is_av()).collect(); - for l in &candidates { - opts.push(format!("{}. {}", idx_of(l.id), l.name)); - } - opts.push("-".into()); - for k in MatteKind::ALL { - opts.push(k.label().into()); + let mut candidates: Vec<&Layer> = vec![]; + if widgets::popup_is_open(ui, pop) { + for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id && l.source.is_av()) { + opts.push(format!("{}. {}", i + 1, l.name)); + candidates.push(l); + } + opts.push("-".into()); + for k in MatteKind::ALL { + opts.push(k.label().into()); + } } if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) { if i == 0 { @@ -1340,9 +1353,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if presp.clicked() { widgets::open_popup(ui, pop); } - let cands: Vec<&Layer> = comp.layers.iter().filter(|l| l.id != layer.id).collect(); + let mut cands: Vec<&Layer> = vec![]; let mut popts = vec!["None".to_string(), "-".to_string()]; - popts.extend(cands.iter().map(|l| format!("{}. {}", idx_of(l.id), l.name))); + if widgets::popup_is_open(ui, pop) { + for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id) { + popts.push(format!("{}. {}", i + 1, l.name)); + cands.push(l); + } + } if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) { let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) }; actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par}))); @@ -1839,7 +1857,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Alt-drag the first or last key of a selected group: scale the group in time. if kresp.drag_started() && ui.input(|i| i.modifiers.alt) - && let Some(c) = app.session.active_comp().cloned() + && let Some(c) = app.session.active_comp_arc() { super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds()); } @@ -1889,7 +1907,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // Alt-drag key group scaling (follows the pointer until release). - if let Some(c) = app.session.active_comp().cloned() { + if let Some(c) = app.session.active_comp_arc() { super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut actions); } // Graph editor. diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index 02c59636..b264d68a 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -152,7 +152,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { run(app, &ctx, "track.stabilize", src_p.clone()); } y += 34.0; - let Some(comp) = app.session.active_comp().cloned() else { + let Some(comp) = app.session.active_comp_arc() else { p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, "Open a composition to track motion.", Tokens::ui(12.0), t.text_faint); return; }; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cb897125..6de363ef 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -327,7 +327,7 @@ fn aux_views( /// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle. fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect { let Some(lv) = app.session.state.locked_viewer else { return full }; - let Some(comp) = app.session.project.comp(lv.comp).cloned() else { return full }; + let Some(comp) = app.session.project.comp_arc(lv.comp) else { return full }; let name = app.session.project.item(lv.comp).map(|i| i.name.clone()).unwrap_or_default(); let gap = 2.0; let mid = full.center().x; @@ -410,7 +410,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { empty_state(app, ui, rect); return; }; - let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1920, 1080, Default::default(), Tick::ZERO)); + let comp = app.session.project.comp_arc(cid).unwrap_or_else(|| effectcraft_engine::project::Comp::new(1920, 1080, Default::default(), Tick::ZERO).into()); let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let p = ui.painter().clone(); VIEW_CAM.with(|c| c.set(app.session.view_camera(cid))); @@ -1628,7 +1628,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], && matches!(kind, "rect" | "ellipse") { // Mask in layer space: invert the layer transform. - let comp = app.session.active_comp().cloned(); + let comp = app.session.active_comp_arc(); if let Some(comp) = comp { let ectx = EvalCtx { project: &app.session.project, diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 57434526..7d76ab68 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -561,6 +561,9 @@ pub struct UiState { /// Horizontal scroll of the Project panel's optional columns (Name and Label stay put). #[serde(default)] pub project_hscroll: f32, + /// Vertical scroll of the Project panel's item list (points); only visible rows are drawn. + #[serde(default)] + pub project_scroll: f32, /// Effect Controls twirl state (group uids that are collapsed). pub fx_closed: BTreeSet, /// Slider params whose slider row is twirled open (AE hides sliders by default). @@ -652,6 +655,7 @@ impl Default for UiState { project_sort: default_project_sort(), project_sort_desc: false, project_hscroll: 0.0, + project_scroll: 0.0, project_columns: default_project_columns(), fx_closed: BTreeSet::new(), fx_slider_open: BTreeSet::new(), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 184bf9ec..ac8a7be1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -238,6 +238,11 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String] chosen } +/// Whether a [`popup_menu`] is open (build its options only then: they can be long). +pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { + ui.data(|d| d.get_temp(id.with("open")).unwrap_or(false)) +} + pub fn open_popup(ui: &Ui, id: egui::Id) { ui.data_mut(|d| d.insert_temp(id.with("open"), true)); } diff --git a/crates/ui-egui/tests/ui_large_project.rs b/crates/ui-egui/tests/ui_large_project.rs new file mode 100644 index 00000000..5fd00dfe --- /dev/null +++ b/crates/ui-egui/tests/ui_large_project.rs @@ -0,0 +1,116 @@ +//! M13.14: the UI with a large project. The Project panel scrolls (only the rows in view are +//! drawn), reveals an item selected elsewhere and renders thumbnails off the UI thread; the +//! Timeline reaches the last of thousands of layers; the Progress panel lists the footage check +//! started by a lazy open. + +use effectcraft_engine::Session; +use effectcraft_engine::perf::{LargeSpec, large_project}; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui_kittest::Harness; +use serde_json::json; + +fn spec() -> LargeSpec { + LargeSpec { comps: 8, main_layers: 1500, comp_layers: 3, footage: 90, sequence_frames: 12, nest_depth: 3, expression_every: 5, ..Default::default() } +} + +fn harness(s: Session) -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn large() -> Session { + let mut s = Session::default(); + s.replace_project(large_project(&spec()), None); + s +} + +#[test] +fn project_panel_scrolls_and_reveals_the_selection() { + let mut h = harness(large()); + let folders: Vec = h.state().session.project.items.values().filter(|i| i.is_folder()).map(|i| i.id.0).collect(); + h.state_mut().ui.project_open_folders.extend(folders); + h.state_mut().show_panel(PanelKind::Project); + 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 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(); + 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"); + assert!(h.state().ui.project_scroll > 0.0); + // Scrolling to the end shows the last rows. + h.state_mut().ui.project_scroll = 1.0e6; + 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] +fn comp_thumbnail_renders_off_the_ui_thread() { + let mut h = harness(large()); + let main = h.state().session.active_comp_id().unwrap(); + h.state_mut().session.execute("project.select", json!({"items": [main.0]})).unwrap(); + h.state_mut().show_panel(PanelKind::Project); + // The frame that asks for the thumbnail doesn't wait for it; it lands a few frames later. + 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() { + landed = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(landed, "thumbnail texture"); +} + +#[test] +fn timeline_reaches_the_last_of_many_layers() { + let mut h = harness(large()); + h.state_mut().show_panel(PanelKind::Timeline); + h.state_mut().ui.maximized = Some(PanelKind::Timeline); + let last = h.state().session.active_comp().unwrap().layers.last().unwrap().id.0; + let first = h.state().session.active_comp().unwrap().layers[0].id.0; + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{first}.bar")).is_some()); + assert!(h.state().auto.find(&format!("timeline.layer.{last}.bar")).is_none(), "off-screen rows aren't drawn"); + h.state_mut().ui.timeline.scroll_y = 1.0e7; + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{last}.bar")).is_some()); + // Track matte and parent menus list every layer only while open. + let pop = h.state().auto.find(&format!("timeline.layer.{last}.parent")).cloned(); + assert!(pop.is_some()); +} + +#[test] +fn lazy_open_lists_the_footage_check_in_the_progress_panel() { + let path = std::env::temp_dir().join(format!("ec-ui-lazy-open-{}.ecproj", std::process::id())); + let mut s = large(); + s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap(); + let mut o = Session { check_footage_on_open: true, ..Default::default() }; + o.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert!(o.jobs().iter().any(|j| j.kind == "footageCheck")); + let mut h = harness(o); + h.state_mut().show_panel(PanelKind::Progress); + // The check finishes in the background; polling applies it: every file is missing. + for _ in 0..500 { + h.step(); + if h.state().session.tasks.is_empty() { + break; + } + 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(); + 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/agents.md b/docs/agents.md index 330f24a9..e0395aae 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -47,14 +47,18 @@ The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MC | Tool | What it does | |---|---| -| `list_commands {filter?, enabled_only?}` | Discover command ids, their param docs, and whether each can run now. | -| `execute_command {command, params?}` | Run any command (undoable). | +| `list_commands {filter?, enabled_only?, schemas?}` | Discover command ids, their param docs (and, with `schemas`, a JSON Schema each), and whether each can run now. | +| `describe_command {command}` | One command in full: label, menu, shortcut, params doc, JSON `schema`, enabled / `why`. | +| `execute_command {command, params?}` | Run any command (undoable). Unknown parameter keys are rejected with the accepted list. | +| `get_state` | Editor state (active comp, time, selections, tool) plus `app`: version, command and effect counts, export formats, parity summary. | | `run_script {code, name?}` | Run JavaScript with the After Effects-style scripting object model (`app.project`, `comp.layers.addText(…)`, `layer.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(…)`…). Returns `{ok, result, output, error: {message, line, column}}`; edits are undoable. | | `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. | | `get_layer {layer, comp?, time?, flat?}` | A layer's property tree. Every node has a `path`. | | `get_property {layer, path, comp?, time?}` | Value at a time, keyframes and expression. | | `set_property {layer, path, value?, time?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe; with `expression` it sets an expression. | | `add_keyframe {layer, path, time+value \| keys:[...], interpolation?}` | Adds keys, then optionally applies linear/bezier/hold/easyEase. | +| `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. | +| `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call; returns the instance path (`effects/#n`) and its parameter paths. | | `render_frame {comp?, time?, max_side?, path?, inline?}` | Returns a PNG image of a frame. | | `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. | | `undo {steps?}` / `redo {steps?}` | History. | @@ -78,6 +82,27 @@ instead of being ignored. 4. `execute_command {"command":"effect.apply","params":{"layer":2,"effect":"Gaussian Blur"}}`, then `set_property {"layer":2,"path":"effects/#1/blurriness","value":8}` 5. `render_frame {"time":1.0}` lets you look at the result, and `save_project {"path":"intro.ecproj"}` saves it. +### Cookbook + +Short recipes; every step is one tool call. + +| Goal | Calls | +|---|---| +| Orient yourself | `get_state` → `get_project` → `get_comp {}` | +| Find the right command | `list_commands {"filter":"mask"}` → `describe_command {"command":"mask.new"}` (params + JSON Schema) | +| Title card | `execute_command comp.new {...}` → `execute_command layer.newText {"text":"Hi","size":120}` → `render_frame {"time":0}` | +| Animate | `add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"}` | +| Effect with settings | `list_effects {"filter":"glow"}` → `add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}}` → `set_property {"layer":2,"path":"effects/#1/intensity","value":2}` | +| Drive with an expression | `set_property {"layer":2,"path":"transform/rotation","expression":"time*90"}` | +| Inspect a layer | `get_layer {"layer":2,"flat":true}` (every property with its `path`, value, key count) | +| Check the result | `render_frame {"time":1.5,"max_side":640}`; in bridge mode `screenshot {"panel":"Timeline"}` | +| Undo a mistake | `undo {}`, or `history {}` then `history {"goto": n}` | +| Missing media after opening | `execute_command footage.check {"wait":true}` → `{checked, missing, probed}`; then `file.findMissing {"what":"footage"}` | +| Save | `save_project {"path":"out.ecproj"}` | + +The desktop app checks footage in the background after every open (a "Checking footage" job in +the Progress panel, `jobs.list`); headless sessions run `footage.check` when they want it. + ### Motion tracking Trackers live on the tracked layer (`Motion Trackers ▸ Tracker n ▸ Track Point n`) and are driven @@ -273,6 +298,7 @@ on stdout. Errors print `{"error": ...}` and exit with status 1; usage errors ex ```sh effectcraft-cli info --json effectcraft-cli commands --filter keys +effectcraft-cli exec --list --schemas --json # every command with its params JSON Schema effectcraft-cli exec comp.new --params '{"name":"Main","width":1280,"height":720}' --empty --save-as main.ecproj effectcraft-cli exec layer.newSolid '{"color":"#3366ff"}' main.ecproj --save effectcraft-cli props Main '#1' --project main.ecproj # flat property list with paths diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7763ee03..584a83e9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -34,7 +34,13 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params Useful query commands for `engine.execute` (they aren't journaled or undoable): `project.summary`, `comp.info {comp?}`, `layer.tree {layer, comp?, depth?, time?}` (every node has a `path`), -`prop.get {layer, path, comp?, time?}`, `editor.state`, `command.list {filter?, enabledOnly?}`. +`prop.get {layer, path, comp?, time?}`, `editor.state`, `command.list {filter?, enabledOnly?, schemas?}`, +`command.describe {command}` (params doc and JSON Schema), `app.capabilities` (version, counts, +export formats, parity summary), `jobs.list` (Progress panel jobs, e.g. the background +`footage.check` after File ▸ Open). + +Every engine command is reachable through `engine.execute`, and every interactive widget of every +panel registers an element id; both are enforced by `crates/ui-egui/tests/ui_agent_audit.rs`. ## UI state