diff --git a/Cargo.lock b/Cargo.lock index c53ed15f..9e42c6b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1183,6 +1183,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 27a118de..aa6c7384 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -4,6 +4,7 @@ //! effectcraft-cli info project + engine summary //! effectcraft-cli commands [--filter TEXT] [--enabled] list engine commands //! effectcraft-cli exec [--params JSON] run one command +//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`) //! effectcraft-cli run [ …] run several commands in order //! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) //! effectcraft-cli get [--time S] read a property @@ -17,6 +18,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 [--adv3d]] 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 //! @@ -38,7 +41,7 @@ use serde_json::{Value, json}; const USAGE: &str = "usage: effectcraft-cli [args] [--json] info project + engine summary commands [--filter TEXT] [--enabled] list engine commands - exec [--params JSON] run one engine command + exec [--params JSON] run one engine command (exec --list: list them, as `commands`) run [ ...] run several commands in order props [--flat] [--time S] a layer's property tree with paths get [--time S] read a property @@ -54,6 +57,9 @@ const USAGE: &str = "usage: effectcraft-cli Result<(), Failure> { } } } - "commands" => { - let mut b = backend(args, false)?; - let v = tool(&mut b, "list_commands", json!({"filter": args.opt("--filter"), "enabled_only": args.flag("--enabled")}))?; - if json_out { - emit(&v, true); - } else { - for c in v.as_array().into_iter().flatten() { - println!( - "{:<36} {:<36} {:<16} {}", - c["id"].as_str().unwrap_or(""), - c["label"].as_str().unwrap_or(""), - c["shortcut"].as_str().unwrap_or(""), - c["params"].as_str().unwrap_or("") - ); - } - } - } + "commands" => list_commands_cmd(args, json_out)?, + "exec" if args.flag("--list") => list_commands_cmd(args, json_out)?, "script" => script_cmd(args, json_out)?, "exec" => { let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") }; @@ -635,9 +629,33 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> { Ok(()) } +/// `commands` / `exec --list`: every engine command (id, label, shortcut, params doc; with +/// `--schemas` also each command's params JSON Schema). +fn list_commands_cmd(args: &Args, json_out: bool) -> Result<(), Failure> { + let mut b = backend(args, false)?; + let v = tool(&mut b, "list_commands", json!({"filter": args.opt("--filter"), "enabled_only": args.flag("--enabled"), "schemas": args.flag("--schemas")}))?; + if json_out { + emit(&v, true); + } else { + for c in v.as_array().into_iter().flatten() { + println!( + "{:<36} {:<36} {:<16} {}", + c["id"].as_str().unwrap_or(""), + c["label"].as_str().unwrap_or(""), + c["shortcut"].as_str().unwrap_or(""), + c["params"].as_str().unwrap_or("") + ); + } + } + Ok(()) +} + /// `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})), @@ -660,6 +678,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-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index d7c48762..6ec33992 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -44,6 +44,30 @@ fn info_and_commands() { assert!(ids.iter().all(|i| i.contains("layer.new") || !i.is_empty())); } +/// The agent-interface audit (CLI side): `exec --list` lists every registered engine command +/// with its label, params doc and (with `--schemas`) params JSON Schema, and `exec` routes every +/// id to its command (an unknown parameter is rejected by that command). +#[test] +fn exec_list_covers_every_engine_command() { + let v = ok_json(&["exec", "--list", "--schemas", "--empty"]); + let listed: std::collections::HashMap<&str, &Value> = v.as_array().unwrap().iter().map(|c| (c["id"].as_str().unwrap(), c)).collect(); + let specs = effectcraft_engine::command_specs(); + assert_eq!(listed.len(), specs.len()); + for spec in specs { + let c = listed.get(spec.id).unwrap_or_else(|| panic!("`exec --list` misses {}", spec.id)); + assert_eq!(c["label"], spec.label); + // Empty docs (`{}`) are dropped from the compact listing. + assert!(c["params"] == spec.params || (spec.params == "{}" && c["params"].is_null()), "{}: {}", spec.id, c["params"]); + assert_eq!(c["schema"]["type"], "object", "{}", spec.id); + } + // Routing: a few ids through a real process (all ids are checked in-process by the MCP audit). + for id in ["comp.new", "layer.newText", "effect.apply", "file.saveAs", "render.frame"].into_iter().filter(|i| listed.contains_key(i)) { + let (code, v) = run_json(&["exec", id, r#"{"__audit":1}"#, "--empty"]); + assert_eq!(code, 1, "{id}: {v}"); + assert!(v["error"].as_str().unwrap().contains("unknown parameter"), "{id}: {v}"); + } +} + #[test] fn exec_set_get_with_saved_project() { let proj = tmp("edit.ecproj"); 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/automation/src/tests.rs b/crates/automation/src/tests.rs index 94706665..f78447b9 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -338,3 +338,58 @@ fn puppet_rigging_over_mcp() { let text = c[0]["text"].as_str().unwrap(); assert!(text.contains("puppet.follow") && text.contains("puppet.selectPins"), "{text}"); } + +/// The agent-interface audit (MCP side): every registered engine command is listed by +/// `list_commands` with a label, params doc and JSON Schema, described by `describe_command`, +/// and routed by `execute_command` (an unknown parameter is rejected by that very command +/// without running it). +#[test] +fn every_engine_command_is_reachable_over_mcp() { + let mut s = server(); + let tools = rpc(&mut s, 1, "tools/list", json!({}))["tools"].as_array().unwrap().clone(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for want in ["list_commands", "describe_command", "execute_command", "add_effect", "list_effects", "get_state", "render_frame", "get_layer", "set_property"] + { + assert!(names.contains(&want), "{want}"); + } + let listed = call_json(&mut s, "list_commands", json!({"schemas": true})); + let listed: std::collections::HashMap<&str, &Value> = listed.as_array().unwrap().iter().map(|c| (c["id"].as_str().unwrap(), c)).collect(); + let specs = effectcraft_engine::command_specs(); + assert_eq!(listed.len(), specs.len()); + for spec in specs { + let c = listed.get(spec.id).unwrap_or_else(|| panic!("{} not listed", spec.id)); + assert!(!spec.label.trim().is_empty(), "{}: no label", spec.id); + assert!(spec.params.trim_start().starts_with('{'), "{}: params doc {:?}", spec.id, spec.params); + assert_eq!(c["schema"]["type"], "object", "{}", spec.id); + assert!(c["schema"]["properties"].is_object(), "{}", spec.id); + let d = call_json(&mut s, "describe_command", json!({"command": spec.id})); + assert_eq!(d["schema"], c["schema"], "{}", spec.id); + let (content, err) = call(&mut s, "execute_command", json!({"command": spec.id, "params": {"__audit": 1}})); + let text = content[0]["text"].as_str().unwrap_or_default(); + assert!(err && text.contains("unknown parameter") && text.contains(spec.id), "{}: {text}", spec.id); + } +} + +#[test] +fn high_level_tools_add_effect_and_report_state() { + let mut s = server(); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 48, "duration": 1}})); + let l = call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"color": "#808080"}}))["layer"].clone(); + let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian"})); + assert!(fx.as_array().unwrap().iter().any(|e| e["id"] == "ec.blur.gaussian" && e["gpu"].is_boolean()), "{fx}"); + let r = call_json(&mut s, "add_effect", json!({"layer": l, "effect": "Gaussian Blur", "values": {"blurriness": 7}})); + assert_eq!(r["path"], "effects/#1", "{r}"); + assert!(r["params"].as_array().unwrap().iter().any(|p| p["path"] == "effects/#1/blurriness" && p["value"] == 7.0), "{r}"); + // A second effect lands at #2. + let r = call_json(&mut s, "add_effect", json!({"layer": l, "effect": "ec.color.levels"})); + assert_eq!(r["path"], "effects/#2", "{r}"); + let p = call_json(&mut s, "get_property", json!({"layer": l, "path": "effects/#1/blurriness"})); + assert_eq!(p["value"], 7.0, "{p}"); + let st = call_json(&mut s, "get_state", json!({})); + assert!(st["state"].is_object() && st["app"]["commands"].as_u64().unwrap() > 500, "{st}"); + assert!(st["app"]["effects"]["total"].as_u64().unwrap() > 200, "{st}"); + assert!(st["app"]["parity"]["summary"].as_str().unwrap().contains("parity"), "{st}"); + // Unknown effects fail cleanly. + let (c, err) = call(&mut s, "add_effect", json!({"layer": l, "effect": "No Such Effect"})); + assert!(err && c[0]["text"].as_str().unwrap().contains("unknown effect"), "{c:?}"); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0ac31b95..42612c0e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -117,7 +117,8 @@ fn params_of(v: Option<&Value>) -> Result { // ------------------------------------------------------------------ engine tools fn list_commands(b: &mut Backend, a: &Value) -> Result { - let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only"))]))?; + let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true)); + let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?; // Drop empty fields to keep the listing compact. let list: Vec = r .as_array() @@ -140,6 +141,53 @@ fn execute_command(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec(id, params)?) } +fn describe_command(b: &mut Backend, a: &Value) -> Result { + let id = get(a, "command").or(get(a, "id")).and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `command` (e.g. `layer.newText`)".into()))?; + json_reply(b.exec("command.describe", json!({"command": id}))?) +} + +fn list_effects(b: &mut Backend, a: &Value) -> Result { + json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?) +} + +/// `effect.apply` on one layer, then set parameters on the new instance by param id. +fn add_effect(b: &mut Backend, a: &Value) -> Result { + let (layer, effect, comp) = (need(a, "layer")?, need(a, "effect")?, get(a, "comp")); + let count = |t: &Value| effects_group(t).and_then(|g| g.get("children")).and_then(Value::as_array).map_or(0, Vec::len); + let before = b.exec("layer.tree", obj(&[("layer", Some(layer)), ("comp", comp)]))?; + let index = count(&before) + 1; + let mut p = obj(&[("effect", Some(effect)), ("comp", comp)]); + p["layers"] = json!([layer]); + b.exec("effect.apply", p)?; + let prefix = format!("effects/#{index}"); + let mut set = vec![]; + if let Some(vals) = get(a, "values").and_then(Value::as_object) { + for (k, v) in vals { + let path = format!("{prefix}/{k}"); + b.exec("prop.set", obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))]))?; + set.push(path); + } + } + let tree = b.exec("layer.tree", obj(&[("layer", Some(layer)), ("comp", comp)]))?; + let inst = effects_group(&tree).and_then(|g| g.get("children")).and_then(Value::as_array).and_then(|c| c.get(index - 1)).cloned(); + let mut params = vec![]; + if let Some(i) = &inst { + flatten(i, &mut params); + } + json_reply(json!({"layer": tree["id"], "index": index, "path": prefix, "name": inst.as_ref().map(|i| i["name"].clone()), "set": set, "params": params})) +} + +/// The `effects` group of a `layer.tree` reply. +fn effects_group(tree: &Value) -> Option<&Value> { + tree["properties"]["children"].as_array()?.iter().find(|c| c["path"] == "effects") +} + +fn get_state(b: &mut Backend, _: &Value) -> Result { + let state = b.exec("editor.state", json!({}))?; + let caps = b.exec("app.capabilities", json!({}))?; + json_reply(json!({"state": state, "app": caps})) +} + fn run_script(b: &mut Backend, a: &Value) -> Result { let code = get(a, "code").and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `code` (JavaScript)".into()))?; let mut p = json!({"code": code}); @@ -414,13 +462,21 @@ static TOOLS: &[ToolDef] = &[ schema( json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, - "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."} + "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}, + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."} }), &[], ) }, run: list_commands, }, + ToolDef { + name: "describe_command", + description: "One command in full: id, label, menu path, shortcut, the `params` doc, a JSON Schema of its parameters (`schema`: properties, required, additionalProperties) and whether it can run now (`why` if not). Use before execute_command when unsure of the parameters; execute_command rejects unknown keys.", + bridge_only: false, + schema: || schema(json!({"command": {"type": "string", "description": "Command id, e.g. `layer.newText`."}}), &["command"]), + run: describe_command, + }, ToolDef { name: "execute_command", description: "Run any engine command by id with JSON params; edits are undoable. Examples: comp.new {\"name\":\"Main\",\"width\":1920,\"height\":1080,\"frameRate\":30,\"duration\":5}; layer.newText {\"text\":\"Hello\",\"size\":120,\"fill\":\"#ffffff\"} (returns {\"layer\":id}); layer.newSolid {\"color\":\"#3366ff\"}; layer.newShape; effect.apply {\"layer\":3,\"effect\":\"Gaussian Blur\"}; time.set {\"time\":2.5}; layer.select {\"layers\":[3]}. Layers are referenced by id, \"#n\" or name; times are seconds.", @@ -528,6 +584,36 @@ static TOOLS: &[ToolDef] = &[ }, run: add_keyframe, }, + ToolDef { + name: "add_effect", + description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.", + bridge_only: false, + schema: || { + schema( + json!({ + "layer": layer_s(), "comp": comp_s(), + "effect": {"type": "string", "description": "Effect id or name, e.g. `Gaussian Blur`, `Glow`, `Curves`, `ec.color.levels`."}, + "values": {"type": "object", "description": "Parameter id -> value (ids are in the returned `params` paths), e.g. {\"blurriness\": 12}.", "additionalProperties": true} + }), + &["layer", "effect"], + ) + }, + run: add_effect, + }, + ToolDef { + name: "list_effects", + description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.", + bridge_only: false, + schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]), + run: list_effects, + }, + ToolDef { + name: "get_state", + description: "Where things stand: the editor state (active comp, current time, selected layers/keys/items, open comps, tool, work area) plus what this build can do (`app`: version, command and effect counts, GPU effects, import/export formats, After Effects parity summary). Cheap; call it to re-orient after a series of edits.", + bridge_only: false, + schema: || schema(json!({}), &[]), + run: get_state, + }, ToolDef { name: "render_frame", description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). `path` also writes the PNG to disk; `inline: false` returns only the metadata. Use it to check your work.", diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index c3c1911f..dd55130a 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -25,6 +25,12 @@ const EXT: &str = "ecproj"; /// Auto-save bookkeeping of a session. #[derive(Clone, Debug, Default)] pub struct AutoSaveState { + /// Serialise and write on a background thread (frontends with an event loop turn this on; + /// ignored on wasm32). The UI thread then only picks the slot and snapshots the project + /// (an `Arc` clone), so a large project's auto-save never stalls a frame. + pub background: bool, + /// The background write in flight, if any. + pub pending: Option, /// Wall-clock seconds of the last auto-save (or of the first tick). pub last: Option, /// Slot written last in this session. @@ -37,6 +43,37 @@ pub struct AutoSaveState { pub sentinel: bool, } +/// A background auto-save write ([`AutoSaveState::background`]). +#[derive(Clone)] +pub struct PendingWrite { + pub path: PathBuf, + handle: std::sync::Arc>>>>, +} + +impl std::fmt::Debug for PendingWrite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PendingWrite").field("path", &self.path).finish() + } +} + +impl PendingWrite { + pub fn spawn(path: PathBuf, work: impl FnOnce() -> std::io::Result<()> + Send + 'static) -> std::io::Result { + let h = std::thread::Builder::new().name("autosave".into()).spawn(work)?; + Ok(PendingWrite { path, handle: std::sync::Arc::new(std::sync::Mutex::new(Some(h))) }) + } + pub fn is_finished(&self) -> bool { + self.handle.lock().map(|h| h.as_ref().is_none_or(|h| h.is_finished())).unwrap_or(true) + } + /// Wait for the write; its result (`Ok` if already collected). + pub fn join(&self) -> std::io::Result<()> { + let h = self.handle.lock().ok().and_then(|mut h| h.take()); + match h { + Some(h) => h.join().unwrap_or_else(|_| Err(std::io::Error::other("auto-save thread panicked"))), + None => Ok(()), + } + } +} + /// File-name stem of a project (`/a/Intro.ecproj` → `Intro`; untitled → `Untitled Project`). pub fn project_stem(path: Option<&str>) -> String { path.and_then(|p| Path::new(p).file_stem()).map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled Project".into()) @@ -103,20 +140,41 @@ pub fn write_in( last_slot: Option, json: &str, ) -> std::io::Result<(PathBuf, u32)> { + let plan = plan_in(fs, prefs, project, fallback, last_slot)?; + plan.write(fs, json.as_bytes())?; + Ok((plan.path, plan.slot)) +} + +/// Where the next auto-save goes ([`plan_in`]). +#[derive(Clone, Debug)] +pub struct WritePlan { + pub path: PathBuf, + pub slot: u32, + /// Slots above the (lowered) maximum, removed after the write. + pub stale: Vec, +} + +impl WritePlan { + pub fn write(&self, fs: &dyn FileOps, data: &[u8]) -> std::io::Result<()> { + fs.write(&self.path, data)?; + for p in &self.stale { + let _ = fs.remove(p); + } + Ok(()) + } +} + +/// Pick the next auto-save slot (without writing anything). +pub fn plan_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option) -> std::io::Result { let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?; let stem = project_stem(project); let max = prefs.auto_save.max_versions.max(1); let have = existing_in(fs, &dir, &stem); let slot = next_slot(&have, last_slot, max); let path = dir.join(file_name(&stem, slot)); - fs.write(&path, json.as_bytes())?; // Max versions lowered: drop slots above it. - for (n, p, _) in have { - if n > max { - let _ = fs.remove(&p); - } - } - Ok((path, slot)) + let stale = have.into_iter().filter(|(n, _, _)| *n > max).map(|(_, p, _)| p).collect(); + Ok(WritePlan { path, slot, stale }) } /// The newest auto-save of a project, if any. diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 0060762b..91575239 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -358,8 +358,12 @@ fn list(_: &mut Session, p: &Value) -> Result { let filter = str_p(p, "filter").map(str::to_ascii_lowercase); let v: Vec = effectcraft_effects::all() .into_iter() - .filter(|e| filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()))) - .map(|e| json!({"id": e.id, "name": e.name, "category": e.category, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::>()})) + .filter(|e| { + filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f)) + }) + .map(|e| { + json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::>()}) + }) .collect(); Ok(json!(v)) } 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 26447200..a5217849 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -851,6 +851,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/commands/mod.rs b/crates/engine/src/commands/mod.rs index e2b1ea64..4768ef99 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -215,7 +215,16 @@ pub fn text_preset_list() -> Vec<(String, String)> { } pub fn find(id: &str) -> Option<&'static CommandSpec> { - command_specs().iter().find(|c| c.id == id) + // Every command execution looks its spec up: index the ~2,000 ids once. + static INDEX: OnceLock> = OnceLock::new(); + let index = INDEX.get_or_init(|| { + let mut m = std::collections::HashMap::new(); + for (i, c) in command_specs().iter().enumerate() { + m.entry(c.id).or_insert(i); + } + m + }); + index.get(id).map(|&i| &command_specs()[i]) } // ---------- parameter validation (agents) ---------- @@ -258,6 +267,63 @@ pub fn accepted_params(doc: &str) -> Option> { None } +/// Top-level pieces of a params doc (split at depth-0 commas), e.g. `{layer?, path|prop, value}` +/// → `["layer?", "path|prop", "value"]`. +fn doc_pieces(doc: &str) -> Option> { + let body = doc.trim().strip_prefix('{')?; + let (mut out, mut cur, mut depth) = (Vec::new(), String::new(), 0i32); + for c in body.chars() { + match c { + '{' | '[' | '(' => depth += 1, + ']' | ')' => depth -= 1, + '}' if depth == 0 => { + if !cur.trim().is_empty() { + out.push(cur.trim().to_string()); + } + return Some(out); + } + '}' => depth -= 1, + ',' if depth == 0 => { + if !cur.trim().is_empty() { + out.push(cur.trim().to_string()); + } + cur.clear(); + continue; + } + _ => {} + } + cur.push(c); + } + None +} + +/// The JSON Schema of a command's parameters, derived from its params doc: one property per +/// accepted key (described by its piece of the doc), no others. MCP `describe_command` and +/// `list_commands {schemas: true}` serve it. +pub fn params_schema(spec: &CommandSpec) -> Value { + let mut props = serde_json::Map::new(); + let mut required = vec![]; + for piece in doc_pieces(spec.params).unwrap_or_default() { + let keys = accepted_params(&format!("{{{piece}}}")).unwrap_or_default(); + let alternatives = keys.len() > 1; + for k in keys { + // `key?` is optional; so is any key with alternatives (`path|prop`). + let optional = alternatives || piece.contains(&format!("{k}?")); + if !optional && !required.contains(&k) { + required.push(k.clone()); + } + props.insert(k, serde_json::json!({"description": piece})); + } + } + serde_json::json!({ + "type": "object", + "description": spec.params, + "properties": props, + "required": required, + "additionalProperties": accepted_params(spec.params).is_none(), + }) +} + /// Keys every command understands (comp targeting, gesture merging) and accepted aliases. const ALWAYS_OK: &[&str] = &["comp", "merge"]; const ALIASES: &[(&str, &str)] = &[("layers", "layer"), ("layer", "layers"), ("time", "frame"), ("frameRate", "fps"), ("properties", "property")]; diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 121bd804..267b4605 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -9,6 +9,7 @@ use crate::{EngineError, Result, Session, query}; fn list(s: &mut Session, p: &Value) -> Result { let filter = str_p(p, "filter").map(str::to_ascii_lowercase); let only = b_p(p, "enabledOnly").unwrap_or(false); + let schemas = b_p(p, "schemas").unwrap_or(false); let v: Vec = super::command_specs() .iter() .filter(|c| filter.as_ref().is_none_or(|f| c.id.to_ascii_lowercase().contains(f) || c.label.to_ascii_lowercase().contains(f))) @@ -17,7 +18,12 @@ fn list(s: &mut Session, p: &Value) -> Result { if only && en.is_err() { return None; } - Some(json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": en.is_ok(), "why": en.err()})) + let mut o = + json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": en.is_ok(), "why": en.err()}); + if schemas { + o["schema"] = super::params_schema(c); + } + Some(o) }) .collect(); Ok(json!(v)) @@ -151,13 +157,50 @@ fn layer_tree(s: &mut Session, p: &Value) -> Result { Ok(o) } +/// One command in full, with the JSON Schema of its parameters. +fn describe(s: &mut Session, p: &Value) -> Result { + let id = str_p(p, "command").or(str_p(p, "id")).ok_or_else(|| super::bad("command.describe", "missing `command`"))?; + let c = super::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + let en = (c.enabled)(s); + Ok(json!({ + "id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, + "schema": super::params_schema(c), "undoable": c.journal, "enabled": en.is_ok(), "why": en.err(), + })) +} + +/// What this build can do: counts agents use to orient themselves, and the parity summary. +fn capabilities(s: &mut Session, _: &Value) -> Result { + let fx = effectcraft_effects::registry(); + let formats: Vec = s.exporter.as_ref().map(|e| e.formats().iter().map(|f| json!(format!("{f:?}"))).collect()).unwrap_or_default(); + Ok(json!({ + "version": env!("CARGO_PKG_VERSION"), + "commands": super::command_specs().len(), + "effects": {"total": fx.len(), "gpu": fx.iter().filter(|e| e.gpu).count(), "float": fx.iter().filter(|e| e.float).count()}, + "export": formats, + "import": s.importer.is_some(), + "expressions": s.expr.is_some(), + "scripting": s.script.is_some(), + "gpu": s.accel.is_some(), + "parity": { + "target": "Adobe After Effects 2026", + "summary": PARITY, + "doc": "docs/parity.md", + }, + })) +} + +/// The headline of `docs/parity.md` (kept in sync by hand at each audit). +const PARITY: &str = "≈94% weighted feature parity (80 done / 12 partial / 0 missing of 92 features); 298 of 298 After Effects 2026 effects present"; + fn state(s: &mut Session, _: &Value) -> Result { Ok(serde_json::to_value(&s.state).unwrap_or_default()) } pub fn specs() -> Vec { vec![ - query!("command.list", "List Commands", "{filter?, enabledOnly?}", list), + query!("command.list", "List Commands", "{filter?, enabledOnly?, schemas? (add each command's params JSON Schema)}", list), + query!("command.describe", "Describe Command", "{command} → id, label, menu, shortcut, params doc, JSON `schema`, enabled", describe), + query!("app.capabilities", "App Capabilities", "{} → version, command/effect counts, export formats, parity summary", capabilities), query!("project.summary", "Project Summary", "{}", summary), query!("comp.info", "Composition Info", "{comp?}", comp_info), query!("layer.tree", "Layer Property Tree", "{layer?, comp?, depth?, time? (comp s)} → nodes with `path`", layer_tree), 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 12076b2a..4829395a 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; @@ -23,6 +24,7 @@ pub mod media_browser; pub mod media_cache; pub mod menus; pub mod offload; +pub mod perf; pub mod prefs; pub mod preview; pub mod psd_import; @@ -84,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. @@ -366,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> { @@ -437,6 +450,7 @@ impl Default for Session { job_log: vec![], next_task_id: 1, learn: None, + check_footage_on_open: false, } } } @@ -583,6 +597,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()) } @@ -714,6 +732,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, + } + } + /// [`Session::render_rgba8`], or with `transparent` the frame's own straight alpha (what a /// render with RGB + Alpha channels writes) instead of compositing over the background. pub fn render_rgba8_alpha(&self, comp: ItemId, t: Tick, max_side: u32, transparent: bool) -> Result<(u32, u32, Vec)> { diff --git a/crates/engine/src/perf.rs b/crates/engine/src/perf.rs new file mode 100644 index 00000000..f6f37b98 --- /dev/null +++ b/crates/engine/src/perf.rs @@ -0,0 +1,393 @@ +//! Everyday-operation performance: a large generated project and the engine-side measurements +//! behind `effectcraft-cli bench --ops` (open, save, auto-save, large edits with undo/redo, first +//! frame). The UI-side numbers (first UI frame, timeline and Project panel draw) live in +//! `effectcraft-ui-egui`'s `bench` module. See `docs/architecture.md` ▸ Performance. + +use std::sync::Arc; + +use effectcraft_keyframe::Value as KValue; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{AlphaMode, Comp, Expression, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::Session; + +/// What [`large_project`] builds. +#[derive(Clone, Debug)] +pub struct LargeSpec { + /// Compositions (the main comp, a precomp nesting chain and leaf comps). + pub comps: usize, + /// Layers in the main comp (the timeline stress case). + pub main_layers: usize, + /// Layers in every other comp. + pub comp_layers: usize, + /// Footage items: a third image sequences, a third movies, a third stills. + pub footage: usize, + /// Frames per image sequence. + pub sequence_frames: usize, + /// Depth of the precomp nesting chain under the main comp. + pub nest_depth: usize, + /// Every n-th layer gets expressions (0 = none). + pub expression_every: usize, + /// Folder the footage paths point into (the files need not exist). + pub media_dir: String, +} + +impl Default for LargeSpec { + /// 200 comps, 5,000 layers in the main comp (plus 10 in each other comp), 300 footage items + /// (100 sequences of 240 frames, 100 movies, 100 stills), 20 levels of precomp nesting and + /// expressions on every 4th layer. + fn default() -> Self { + LargeSpec { + comps: 200, + main_layers: 5000, + comp_layers: 10, + footage: 300, + sequence_frames: 240, + nest_depth: 20, + expression_every: 4, + media_dir: "/nonexistent/effectcraft-bench-media".into(), + } + } +} + +impl LargeSpec { + /// A small version for tests. + pub fn small() -> Self { + LargeSpec { comps: 12, main_layers: 300, comp_layers: 4, footage: 30, sequence_frames: 24, nest_depth: 5, expression_every: 3, ..Default::default() } + } + /// Total layers [`large_project`] makes. + pub fn total_layers(&self) -> usize { + self.main_layers + self.comps.saturating_sub(1) * self.comp_layers + } +} + +fn base_comp(name: &str) -> Comp { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": name, "width": 1920, "height": 1080, "frameRate": 30, "duration": 10})).expect("comp.new"); + s.active_comp().expect("comp").clone() +} + +fn footage_item(spec: &LargeSpec, i: usize) -> Footage { + let dir = &spec.media_dir; + let base = Footage { width: 1920, height: 1080, pixel_aspect: 1.0, has_video: true, alpha: AlphaMode::Straight, ..Default::default() }; + match i % 3 { + 0 => { + let frames: Vec = (0..spec.sequence_frames).map(|f| format!("{dir}/seq{i:03}/shot{i:03}_{f:05}.png")).collect(); + Footage { + path: frames.first().cloned().unwrap_or_default(), + kind: FootageKind::Sequence, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(spec.sequence_frames as f64 / 30.0), + codec: "PNG".into(), + sequence: frames, + ..base + } + } + 1 => Footage { + path: format!("{dir}/clip{i:03}.mp4"), + kind: FootageKind::Video, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(20.0), + has_audio: true, + codec: "H.264".into(), + ..base + }, + _ => Footage { path: format!("{dir}/still{i:03}.png"), kind: FootageKind::Still, codec: "PNG".into(), ..base }, + } +} + +fn set_expr(l: &mut Layer, path: &str, text: &str) { + if let Some(p) = l.props.prop_mut(path) { + p.expr = Some(Expression { text: text.into(), enabled: true }); + } +} + +/// One layer of a varied mix: solids, footage, text, shapes, nulls, some with an effect, some +/// with expressions, parented to the layer above every 7th. +fn make_layer(p: &mut Project, comp: &Comp, spec: &LargeSpec, n: usize, solid: ItemId, footage: &[ItemId], prev: Option<&Layer>) -> Layer { + let (src, size) = match n % 5 { + 0 => (LayerSource::Solid { item: solid }, (1920, 1080)), + 1 if !footage.is_empty() => (LayerSource::Footage { item: footage[n % footage.len()] }, (1920, 1080)), + 2 => (LayerSource::Text, (0, 0)), + 3 => (LayerSource::Shape, (0, 0)), + _ => (LayerSource::Null, (100, 100)), + }; + let mut l = build::layer(p, comp, &format!("Layer {n}"), src, size, None); + if n.is_multiple_of(6) + && let Some(fx) = crate::effects::find("ec.blur.gaussian") + { + let mut next = p.next_id; + let mut g = crate::effects::instantiate(fx, &mut Ids(&mut next), fx.name, [1920.0, 1080.0]); + p.next_id = next; + if let Some(b) = g.prop_mut("blurriness") { + b.value = KValue::Scalar(4.0); + } + if let Some(e) = l.props.sub_mut("effects") { + e.children.push(g.into()); + } + } + if spec.expression_every > 0 && n.is_multiple_of(spec.expression_every) { + set_expr(&mut l, "transform/position", "wiggle(2, 20)"); + set_expr(&mut l, "transform/rotation", "time * 30 + index"); + set_expr(&mut l, "transform/opacity", "50 + 50 * Math.sin(time * 2)"); + } + if n.is_multiple_of(7) + && let Some(prev) = prev + { + l.parent = Some(prev.id); + } + // Stagger the bars so the timeline isn't one block. + let span = comp.duration.0.max(1); + let off = (n as i64 * 7919) % span; + l.in_point = Tick(off / 2); + l +} + +/// A large project for performance work (see [`LargeSpec`]). The main comp is named `Main` and +/// is the first comp; the nesting chain is `Nest 1` (inside Main) … `Nest {depth}`. +pub fn large_project(spec: &LargeSpec) -> Project { + let mut p = Project::default(); + let template = base_comp("Main"); + let media = p.add_item("Media", crate::color::Label::None, None, ItemKind::Folder); + let comps_folder = p.add_item("Comps", crate::color::Label::None, None, ItemKind::Folder); + let footage: Vec = (0..spec.footage) + .map(|i| { + let f = footage_item(spec, i); + let name = std::path::Path::new(&f.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + p.add_item(&name, crate::color::Label::None, Some(media), ItemKind::Footage(f)) + }) + .collect(); + let solid = p.add_item( + "Bench Solid", + crate::color::Label::None, + Some(media), + ItemKind::Solid(Solid { color: [0.2, 0.4, 0.8], width: 1920, height: 1080, pixel_aspect: 1.0 }), + ); + + // Leaf comps and the nesting chain are built bottom-up so each precomp layer can point at + // an existing item. + let mut n = 0usize; + let fill = |p: &mut Project, comp: &mut Comp, count: usize, n: &mut usize| { + let mut layers: Vec = Vec::with_capacity(count); + for _ in 0..count { + let l = make_layer(p, comp, spec, *n, solid, &footage, layers.last()); + *n += 1; + layers.push(l); + } + comp.layers = layers; + }; + let others = spec.comps.saturating_sub(1); + let depth = spec.nest_depth.min(others); + // Nesting chain: Nest {depth} (deepest) … Nest 1. + let mut below: Option = None; + for d in (1..=depth).rev() { + let mut c = template.clone(); + fill(&mut p, &mut c, spec.comp_layers.saturating_sub(1), &mut n); + if let Some(b) = below { + let l = build::layer(&mut p, &c, &format!("Nest {}", d + 1), LayerSource::Comp { item: b }, (1920, 1080), None); + c.layers.insert(0, l); + } + below = Some(p.add_item(&format!("Nest {d}"), crate::color::Label::None, Some(comps_folder), ItemKind::Comp(Arc::new(c)))); + } + let mut leaves = vec![]; + for i in depth..others { + let mut c = template.clone(); + fill(&mut p, &mut c, spec.comp_layers, &mut n); + leaves.push(p.add_item(&format!("Shot {:03}", i + 1), crate::color::Label::None, Some(comps_folder), ItemKind::Comp(Arc::new(c)))); + } + // Main: the chain's top, a few leaf precomps, then the bulk. + let mut main = template; + let mut layers = vec![]; + if let Some(b) = below { + layers.push(build::layer(&mut p, &main, "Nest 1", LayerSource::Comp { item: b }, (1920, 1080), None)); + } + for (k, leaf) in leaves.iter().take(spec.main_layers.saturating_sub(1) / 10).enumerate() { + layers.push(build::layer(&mut p, &main, &format!("Shot {:03}", k + 1), LayerSource::Comp { item: *leaf }, (1920, 1080), None)); + } + while layers.len() < spec.main_layers { + let l = make_layer(&mut p, &main, spec, n, solid, &footage, layers.last()); + n += 1; + layers.push(l); + } + main.layers = layers; + p.add_item("Main", crate::color::Label::None, None, ItemKind::Comp(Arc::new(main))); + // `Main` must be the first comp `replace_project` opens: give it the smallest id. + let main_id = p.items.values().find(|i| i.name == "Main").map(|i| i.id).expect("main"); + let first = p.items.keys().next().copied().expect("items"); + if first != main_id { + let item = p.items.remove(&main_id).expect("main"); + let other = p.items.remove(&first).expect("first"); + // Swap ids: references to `first` (the Media folder) are the footage/solid parents. + p.items.insert(first, effectcraft_project::Item { id: first, ..item }); + p.items.insert(main_id, effectcraft_project::Item { id: main_id, ..other }); + for it in p.items.values_mut() { + if it.parent == Some(first) { + it.parent = Some(main_id); + } + } + } + p.fix_next_id(); + p +} + +/// One measurement. +#[derive(Clone, Debug)] +pub struct Measure { + pub name: &'static str, + pub ms: f64, + pub note: String, +} + +impl Measure { + pub fn json(&self) -> Value { + json!({"name": self.name, "ms": (self.ms * 1000.0).round() / 1000.0, "note": self.note}) + } +} + +/// Time `f` (best of `runs`). +pub fn time_best(runs: usize, mut f: impl FnMut() -> T) -> (f64, T) { + let mut best = f64::INFINITY; + let mut out = None; + for _ in 0..runs.max(1) { + let t0 = web_time::Instant::now(); + let r = f(); + best = best.min(t0.elapsed().as_secs_f64() * 1000.0); + out = Some(r); + } + (best, out.expect("ran")) +} + +/// The engine-side everyday operations on a large project: build, save, open (into a session +/// from `fresh`), first frame, small and large edits with undo/redo, auto-save. Files go into +/// `dir`. Returns the measurements and the opened session (for the UI-side numbers). +pub fn ops_bench(spec: &LargeSpec, dir: &std::path::Path, fresh: &dyn Fn() -> Session) -> (Vec, Session) { + let mut out = vec![]; + let (ms, project) = time_best(1, || large_project(spec)); + let comps = project.comps().count(); + let layers: usize = project.comps().map(|(_, c)| c.layers.len()).sum(); + out.push(Measure { name: "generate", ms, note: format!("{comps} comps, {layers} layers, {} items", project.items.len()) }); + + let path = dir.join("bench-large.ecproj"); + let path_s = path.to_string_lossy().to_string(); + let mut s = fresh(); + s.replace_project(project, None); + let (ms, r) = time_best(3, || s.execute("file.saveAs", json!({"path": path_s})).expect("save")); + out.push(Measure { name: "save", ms, note: format!("{} bytes", r["bytes"]) }); + + // Open: what File ▸ Open does, into a fresh session. + let (ms, mut s) = time_best(3, || { + let mut s = fresh(); + s.execute("file.open", json!({"path": path_s})).expect("open"); + 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"); + let (ms, _) = time_best(1, || { + let opts = crate::render::RenderOpts { scale: 0.25, ..Default::default() }; + s.render(cid, Tick::ZERO, opts) + }); + out.push(Measure { name: "firstFrame", ms, note: "Main at 1/4, cold".into() }); + + // A small edit in the big project (one property on one layer): the everyday cost of the + // undo snapshot. + let first = s.active_comp().and_then(|c| c.layers.get(10)).map(|l| l.id.0).expect("layer"); + let mut v = 0.0; + let (ms, _) = time_best(20, || { + v += 1.0; + s.execute("prop.set", json!({"layer": first, "path": "transform/opacity", "value": 50.0 + v})).expect("prop.set") + }); + out.push(Measure { name: "smallEdit", ms, note: "prop.set on one layer of Main".into() }); + let (ms, _) = time_best(5, || { + s.undo(); + s.redo(); + }); + out.push(Measure { name: "smallUndoRedo", ms, note: "one undo + one redo".into() }); + + // A large edit: every layer of Main at once. + let all: Vec = s.active_comp().map(|c| c.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default(); + let mut on = false; + let (ms, _) = time_best(3, || { + on = !on; + s.execute("layer.setSwitch", json!({"layers": all, "switch": "shy", "value": on})).expect("setSwitch") + }); + out.push(Measure { name: "largeEdit", ms, note: format!("shy switch on {} layers", all.len()) }); + let (ms, _) = time_best(3, || s.undo()); + out.push(Measure { name: "largeUndo", ms, note: String::new() }); + let (ms, _) = time_best(3, || s.redo()); + out.push(Measure { name: "largeRedo", ms, note: String::new() }); + + // Auto-save: the full cost and what the UI thread pays. + let auto = dir.join("autosave"); + let _ = s.execute("prefs.set", json!({"values": {"autoSave.location": "custom", "autoSave.folder": auto.to_string_lossy()}})); + s.path = Some(path_s.clone()); + let (ms, _) = time_best(3, || s.autosave_now().expect("autosave")); + out.push(Measure { name: "autosaveSync", ms, note: "serialise + write on the calling thread".into() }); + s.autosave.background = true; + let (ms, _) = time_best(3, || s.autosave_now().expect("autosave")); + out.push(Measure { name: "autosaveUiThread", ms, note: "background auto-save: UI-thread share".into() }); + s.autosave.background = false; + let _ = s.autosave_wait(); + (out, s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn large_project_round_trips_and_opens_main() { + let spec = LargeSpec::small(); + let p = large_project(&spec); + assert_eq!(p.comps().count(), spec.comps); + let layers: usize = p.comps().map(|(_, c)| c.layers.len()).sum(); + assert!(layers >= spec.total_layers() - spec.comps, "{layers}"); + let footage = p.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_))).count(); + assert_eq!(footage, spec.footage); + let back = Project::from_json(&p.to_json()).unwrap(); + assert_eq!(back, p); + let mut s = Session::default(); + s.replace_project(p, None); + assert_eq!(s.active_comp().map(|c| c.layers.len()), Some(spec.main_layers)); + // The nesting chain is `nest_depth` deep. + let mut depth = 0; + let mut cur = s.project.items.values().find(|i| i.name == "Nest 1").map(|i| i.id); + while let Some(c) = cur { + depth += 1; + cur = s.project.comp(c).and_then(|c| { + c.layers.iter().find_map(|l| match l.source { + LayerSource::Comp { item } => Some(item), + _ => None, + }) + }); + } + assert_eq!(depth, spec.nest_depth); + } + + #[test] + fn ops_bench_runs_on_a_small_project() { + let dir = std::env::temp_dir().join(format!("ec-ops-bench-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (m, s) = ops_bench(&LargeSpec::small(), &dir, &Session::default); + let names: Vec<&str> = m.iter().map(|m| m.name).collect(); + for want in ["generate", "save", "open", "firstFrame", "smallEdit", "largeEdit", "largeUndo", "largeRedo", "autosaveSync", "autosaveUiThread"] { + assert!(names.contains(&want), "{want}"); + } + assert!(m.iter().all(|m| m.ms.is_finite() && m.ms >= 0.0)); + assert!(s.autosave.last_path.as_deref().is_some_and(|p| std::path::Path::new(p).is_file())); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index ecb7f44a..e19d0701 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -163,7 +163,9 @@ impl Session { pub(crate) fn note_project_path(&mut self, path: &str) { self.prefs.push_recent(path); self.save_prefs(); - self.autosave = autosave::AutoSaveState { last: self.autosave.last, sentinel: self.autosave.sentinel, ..Default::default() }; + let _ = self.autosave_wait(); + self.autosave = + autosave::AutoSaveState { last: self.autosave.last, sentinel: self.autosave.sentinel, background: self.autosave.background, ..Default::default() }; self.update_sentinel(); } @@ -191,11 +193,34 @@ impl Session { } /// Write an auto-save now (whether or not the project is dirty). Returns its path. + /// + /// With [`autosave::AutoSaveState::background`] the project is serialised and written on a + /// background thread (the path is returned at once; [`Session::autosave_wait`] waits). + /// Auto-saves are compact JSON (opened like any project); File ▸ Save writes pretty JSON. pub fn autosave_now(&mut self) -> Result { - let json = self.project.to_json(); + // One write at a time: the previous one must land before its slot rotates. + self.autosave_wait()?; let root = self.default_autosave_root(); - let (path, slot) = autosave::write_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json) - .map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?; + let fail = |e: std::io::Error| EngineError::Other(format!("auto-save failed: {e}")); + let plan = autosave::plan_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot).map_err(fail)?; + if self.autosave.background && !cfg!(target_arch = "wasm32") { + let project = self.project.clone(); + let config = self.config.clone(); + let job = plan.clone(); + let pending = autosave::PendingWrite::spawn(plan.path.clone(), move || { + let json = project.to_json_compact(); + match &config { + Some(c) => job.write(c.files(), json.as_bytes()), + None => job.write(&crate::config::StdFiles, json.as_bytes()), + } + }) + .map_err(fail)?; + self.autosave.pending = Some(pending); + } else { + let json = self.project.to_json_compact(); + plan.write(self.file_ops(), json.as_bytes()).map_err(fail)?; + } + let (path, slot) = (plan.path, plan.slot); let p = path.to_string_lossy().to_string(); self.autosave.last_slot = Some(slot); self.autosave.saved_revision = Some(self.revision); @@ -204,9 +229,22 @@ impl Session { Ok(p) } + /// Wait for a background auto-save ([`autosave::AutoSaveState::background`]) to land. + pub fn autosave_wait(&mut self) -> Result<()> { + match self.autosave.pending.take() { + Some(w) => w.join().map_err(|e| EngineError::Other(format!("auto-save failed: {e}"))), + None => Ok(()), + } + } + /// Call periodically with wall-clock seconds: auto-saves a dirty project once the interval /// has passed since the last auto-save (or since the first call). Returns the written path. pub fn autosave_tick(&mut self, now: f64) -> Option> { + if self.autosave.pending.as_ref().is_some_and(autosave::PendingWrite::is_finished) + && let Err(e) = self.autosave_wait() + { + return Some(Err(e)); + } let a = &self.prefs.auto_save; if !a.enabled { self.autosave.last = Some(now); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index c651165e..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 { @@ -1042,6 +1050,10 @@ impl Project { pub fn to_json(&self) -> String { serde_json::to_string_pretty(self).unwrap_or_default() } + /// [`Project::to_json`] without indentation: smaller and faster (auto-saves). + pub fn to_json_compact(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } pub fn from_json(s: &str) -> Result { let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?; if p.schema > SCHEMA_VERSION { 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 0ccc46e6..b761985e 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -453,7 +453,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 79199195..2fa4e18b 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); @@ -783,7 +791,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; @@ -891,6 +899,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(Rect::from_min_max(pos2(x - 3.0, nav.min.y - 1.0), pos2(x + 3.0, nav.max.y + 1.0)), 3.0, t.accent); } let nresp = ui.interact(nav, egui::Id::new("tl-nav"), Sense::drag()); + app.auto.add("timeline.navigator", nav, "Time navigator"); if nresp.dragged() { let d = nresp.drag_delta().x as f64 / nav.width() as f64 * comp.duration.seconds(); app.ui.timeline.start = (app.ui.timeline.start + d).clamp(0.0, comp.duration.seconds()); @@ -1211,6 +1220,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0)); lp.rect_filled(lr, 2.0, t.label(layer.label)); let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click()); + app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name()); lresp.context_menu(|ui| { for lab in effectcraft_engine::color::Label::ALL { let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) }; @@ -1363,14 +1373,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 { @@ -1409,9 +1424,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}))); @@ -1474,10 +1494,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Interactions: move body, trim edges. let edge_w = 6.0; let body = ui.interact(bar.shrink2(vec2(edge_w, 0.0)), egui::Id::new(("bar", layer.id.0)), Sense::click_and_drag()); - let lin = - ui.interact(Rect::from_min_max(bar.min, pos2(bar.min.x + edge_w, bar.max.y)), egui::Id::new(("bar-in", layer.id.0)), Sense::drag()); - let lout = - ui.interact(Rect::from_min_max(pos2(bar.max.x - edge_w, bar.min.y), bar.max), egui::Id::new(("bar-out", layer.id.0)), Sense::drag()); + let (in_r, out_r) = + (Rect::from_min_max(bar.min, pos2(bar.min.x + edge_w, bar.max.y)), Rect::from_min_max(pos2(bar.max.x - edge_w, bar.min.y), bar.max)); + let lin = ui.interact(in_r, egui::Id::new(("bar-in", layer.id.0)), Sense::drag()); + let lout = ui.interact(out_r, egui::Id::new(("bar-out", layer.id.0)), Sense::drag()); + app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In"); + app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out"); if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() { ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } @@ -1567,7 +1589,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, "Inverted", Tokens::ui(11.0), t.text_dim); } - let gr = ui.interact(Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y)), egui::Id::new(("grow", uid)), Sense::click()); + 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 { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); } @@ -1781,11 +1805,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Tokens::ui(12.0), if prop.has_expression() { Color32::from_rgb(0xe8, 0x7c, 0x5c) } else { t.text }, ); - let name_resp = ui.interact( - Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)), - egui::Id::new(("pname", uid)), - Sense::click_and_drag(), - ); + let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)); + let name_resp = ui.interact(name_rect, egui::Id::new(("pname", uid)), Sense::click_and_drag()); + app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name); if name_resp.drag_started() { // Drop on the Essential Graphics panel to expose the property. egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid }); @@ -1906,7 +1928,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()); } @@ -1956,7 +1978,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 bfcdde2d..7a0a126f 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -352,7 +352,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; @@ -435,7 +435,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))); @@ -1726,7 +1726,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 c0b10624..9f5f6156 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -565,6 +565,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). @@ -656,6 +659,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_agent_audit.rs b/crates/ui-egui/tests/ui_agent_audit.rs new file mode 100644 index 00000000..246a0039 --- /dev/null +++ b/crates/ui-egui/tests/ui_agent_audit.rs @@ -0,0 +1,169 @@ +//! The agent-interface audit (UI side, M13.14): +//! +//! - every registered engine command is reachable over the control channel (`engine.commands` +//! lists it; `engine.execute` routes it: an unknown parameter is rejected by that command); +//! - every panel kind, opened headless, registers an automation id for every interactive widget +//! it draws: egui's interactive widget rects of the frame are matched against the registered +//! automation elements, and any widget no element covers is a gap. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::control::{self, ControlRequest, Outcome}; +use effectcraft_ui_egui::dock::PanelKind; +use egui::Rect; +use egui_kittest::Harness; +use serde_json::{Value, json}; + +fn session() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn harness(s: Session) -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1680.0, 1020.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) -> Value { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + match control::handle(h.state_mut(), &ctx, &req) { + Outcome::Done(v) => v, + _ => panic!("{method}: no immediate reply"), + } +} + +#[test] +fn every_engine_command_is_reachable_over_the_control_channel() { + let mut h = harness(session()); + let listed = send(&mut h, "engine.commands", json!({})); + let ids: std::collections::HashSet = listed["result"].as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).collect(); + for spec in effectcraft_engine::command_specs() { + assert!(ids.contains(spec.id), "engine.commands misses {}", spec.id); + let v = send(&mut h, "engine.execute", json!({"command": spec.id, "params": {"__audit": 1}})); + let e = v["error"].as_str().unwrap_or_default(); + assert!(v["ok"] == false && e.contains("unknown parameter") && e.contains(spec.id), "{}: {v}", spec.id); + } +} + +/// Interactive widgets (click or drag sense, enabled, visible) of the last frame, in screen +/// points, with their accessibility label when egui knows one. +fn interactive(ctx: &egui::Context) -> Vec<(Rect, String)> { + let screen = ctx.content_rect(); + ctx.viewport(|v| { + let w = &v.this_pass.widgets; + let mut out = vec![]; + for (layer, rects) in w.layers() { + if layer.order == egui::Order::Tooltip || layer.order == egui::Order::Debug { + continue; + } + for r in rects { + let sense = r.sense; + if !(sense.senses_click() || sense.senses_drag()) || !r.enabled { + continue; + } + let ir = r.interact_rect.intersect(screen); + if ir.width() < 2.0 || ir.height() < 2.0 { + continue; + } + let info = w.info(r.id); + // Selectable text (labels sense drags to select their text) is not a control. + if info.is_some_and(|i| i.typ == egui::WidgetType::Label) { + continue; + } + out.push((ir, info.and_then(|i| i.label.clone()).unwrap_or_default())); + } + } + out + }) +} + +/// Automation elements that cover a widget: overlapping at least half of the smaller of the two, +/// and not a container many times the widget's size (a panel or a whole row). +fn covered(w: Rect, elements: &[Rect]) -> bool { + elements.iter().any(|e| { + let i = e.intersect(w); + if i.width() <= 0.0 || i.height() <= 0.0 { + return false; + } + let (aw, ae) = (w.area(), e.area()); + i.area() >= 0.5 * aw.min(ae) && ae <= 40.0 * aw.max(64.0) + }) +} + +/// (panel, widget rect, label) of every interactive widget no automation element covers, for +/// one panel shown maximized. +fn gaps_of(h: &mut Harness<'_, EffectcraftApp>, p: PanelKind) -> Vec { + h.state_mut().show_panel(p); + h.state_mut().ui.maximized = Some(p); + h.run_steps(4); + let ctx = h.ctx.clone(); + let widgets = interactive(&ctx); + let app = h.state(); + let elements: Vec = app + .auto + .previous + .iter() + .chain(app.auto.elements.iter()) + .map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))) + .collect(); + let Some(panel) = + app.auto.find(&format!("panel.{}", p.id())).map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))) + else { + return vec![format!("{}: panel.{} not registered", p.title(), p.id())]; + }; + widgets + .into_iter() + .filter(|(r, _)| panel.contains(r.center())) + .filter(|(r, _)| !covered(*r, &elements)) + .map(|(r, l)| format!("{}: [{:.0},{:.0} {:.0}x{:.0}] {l:?}", p.title(), r.min.x, r.min.y, r.width(), r.height())) + .collect() +} + +#[test] +fn every_panel_registers_automation_ids_for_its_widgets() { + let mut s = session(); + // Content for the selection-driven panels: the title (text with effects), a Render Queue + // item, a comp marker. + s.execute("layer.select", json!({"layers": ["EFFECTCRAFT"]})).unwrap(); + s.execute("renderQueue.add", json!({"format": "png", "output": "/tmp/ec-audit-[#####].png"})).unwrap(); + s.execute("markers.add", json!({})).ok(); + let title = s.active_comp().unwrap().layers.iter().find(|l| l.name == "EFFECTCRAFT").map(|l| l.id.0).unwrap(); + let mut h = harness(s); + h.state_mut().ui.layer_panel = Some(title); + // egui records widget types (to tell labels from controls) with this debug option. + h.ctx.all_styles_mut(|st| st.debug.show_interactive_widgets = true); + let mut gaps = vec![]; + for p in PanelKind::ALL { + gaps.extend(gaps_of(&mut h, p)); + } + // The timeline again with the title twirled open (properties, keys) and in the Graph Editor. + send(&mut h, "ui.set", json!({"timeline": {"openLayers": [title]}})); + gaps.extend(gaps_of(&mut h, PanelKind::Timeline)); + send(&mut h, "ui.set", json!({"timeline": {"graphEditor": true}})); + gaps.extend(gaps_of(&mut h, PanelKind::Timeline)); + send(&mut h, "ui.set", json!({"timeline": {"graphEditor": false}})); + // Outside the panels: the header and the menu bar. + h.state_mut().ui.maximized = None; + h.run_steps(3); + let ctx = h.ctx.clone(); + let widgets = interactive(&ctx); + let app = h.state(); + let elements: Vec = + app.auto.previous.iter().map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))).collect(); + let panels: Vec = app + .auto + .previous + .iter() + .filter(|e| e.id.starts_with("panel.") && e.id.matches('.').count() == 1) + .map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))) + .collect(); + for (r, l) in widgets { + if !panels.iter().any(|p| p.contains(r.center())) && !covered(r, &elements) { + gaps.push(format!("chrome: [{:.0},{:.0} {:.0}x{:.0}] {l:?}", r.min.x, r.min.y, r.width(), r.height())); + } + } + assert!(gaps.is_empty(), "{} interactive widgets without an automation id:\n{}", gaps.len(), gaps.join("\n")); +} 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..e6ffd26e --- /dev/null +++ b/crates/ui-egui/tests/ui_large_project.rs @@ -0,0 +1,120 @@ +//! 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 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:?}"); +} + +#[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 2c9c586a..927b548f 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?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. | | `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. | | `undo {steps?}` / `redo {steps?}` | History. | @@ -79,6 +83,26 @@ 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. ### Cookbook (from end-to-end QA) The scenarios in `crates/automation/tests/qa/` and `apps/effectcraft-cli/tests/qa_template.rs` @@ -341,6 +365,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/architecture.md b/docs/architecture.md index c37511dc..3a9d5627 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -426,3 +426,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. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f70bf3f3..be2e5e44 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