Merge M13.14 agent-interface audit + lazy open (worktree-agent-a5f8967ebd42ce98b)

The branch's commits say M13.14; on main that id is the puppet work, so this arc is M13.21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 17:23:10 -04:00
co-authored by Claude Opus 5.5
38 changed files with 1838 additions and 117 deletions
Generated
+1
View File
@@ -1183,6 +1183,7 @@ dependencies = [
"effectcraft-host",
"effectcraft-render",
"effectcraft-time",
"effectcraft-ui-egui",
"image",
"serde_json",
]
+1
View File
@@ -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 }
+80 -18
View File
@@ -4,6 +4,7 @@
//! effectcraft-cli info project + engine summary
//! effectcraft-cli commands [--filter TEXT] [--enabled] list engine commands
//! effectcraft-cli exec <command-id> [--params JSON] run one command
//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`)
//! effectcraft-cli run <id> <json> [<id> <json> …] run several commands in order
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
//! effectcraft-cli get <comp> <layer> <path> [--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 <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
exec <command-id> [--params JSON] run one engine command
exec <command-id> [--params JSON] run one engine command (exec --list: list them, as `commands`)
run <id> <json> [<id> <json> ...] run several commands in order
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
get <comp> <layer> <path> [--time S] read a property
@@ -54,6 +57,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
--play N renders N consecutive frames with/without the layer cache;
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
bench --ops [--small] [--layers N] [--comps N] [--footage N]
everyday operations on a large generated project: startup, open,
save, auto-save, edits + undo/redo, timeline, Project panel
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
(app.project, comps, layers, properties…); prints writeLn
output and the result; errors exit 1 with file:line:col
@@ -65,6 +71,9 @@ options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge
const VALUED: &[&str] = &[
"--n",
"--play",
"--layers",
"--comps",
"--footage",
"--params",
"--project",
"--bridge",
@@ -360,23 +369,8 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> 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::<Vec<_>>()}), 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;
+24
View File
@@ -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");
+4
View File
@@ -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() {
+55
View File
@@ -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:?}");
}
+88 -2
View File
@@ -117,7 +117,8 @@ fn params_of(v: Option<&Value>) -> Result<Value> {
// ------------------------------------------------------------------ engine tools
fn list_commands(b: &mut Backend, a: &Value) -> Result<Reply> {
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<Value> = r
.as_array()
@@ -140,6 +141,53 @@ fn execute_command(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec(id, params)?)
}
fn describe_command(b: &mut Backend, a: &Value) -> Result<Reply> {
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<Reply> {
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<Reply> {
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<Reply> {
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<Reply> {
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.",
+65 -7
View File
@@ -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<PendingWrite>,
/// Wall-clock seconds of the last auto-save (or of the first tick).
pub last: Option<f64>,
/// 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<std::sync::Mutex<Option<std::thread::JoinHandle<std::io::Result<()>>>>>,
}
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<PendingWrite> {
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<u32>,
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<PathBuf>,
}
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<u32>) -> std::io::Result<WritePlan> {
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.
+6 -2
View File
@@ -358,8 +358,12 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
let v: Vec<Value> = 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::<Vec<_>>()}))
.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::<Vec<_>>()})
})
.collect();
Ok(json!(v))
}
+5
View File
@@ -45,6 +45,11 @@ pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
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}))
}
+9
View File
@@ -851,6 +851,15 @@ pub fn specs() -> Vec<CommandSpec> {
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...",
+67 -1
View File
@@ -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<std::collections::HashMap<&'static str, usize>> = 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<Vec<String>> {
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<Vec<String>> {
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")];
+45 -2
View File
@@ -9,6 +9,7 @@ use crate::{EngineError, Result, Session, query};
fn list(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> = 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<Value> {
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<Value> {
Ok(o)
}
/// One command in full, with the JSON Schema of its parameters.
fn describe(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
let fx = effectcraft_effects::registry();
let formats: Vec<Value> = 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<Value> {
Ok(serde_json::to_value(&s.state).unwrap_or_default())
}
pub fn specs() -> Vec<CommandSpec> {
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),
+192
View File
@@ -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<Footage>),
}
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<Vec<ItemId>>, wait: bool) -> crate::Result<Value> {
let jobs: Vec<Job> = 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<Value> {
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<bool> = 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);
}
}
+66
View File
@@ -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<T> = std::result::Result<T, EngineError>;
pub trait Services: Send + Sync {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>>;
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<learn::Progress>,
/// 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<dyn FnOnce() -> Option<(u32, u32, Vec<u8>)> + 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<std::sync::Arc<Comp>> {
self.project.comp_arc(self.state.active_comp?)
}
pub fn active_comp_id(&self) -> Option<ItemId> {
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<ThumbnailJob> {
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<u8>)> {
+393
View File
@@ -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<String> = (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<ItemId> = (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<Layer> = 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<ItemId> = 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<T>(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<Measure>, 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<u64> = 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);
}
}
+42 -4
View File
@@ -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<String> {
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<Result<String>> {
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);
+12
View File
@@ -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<Arc<Comp>> {
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<Project, ProjectError> {
let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?;
if p.schema > SCHEMA_VERSION {
+126
View File
@@ -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<egui::Event>) -> 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<Measure> {
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<u64> = 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<u64> = 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:?}");
}
}
+4 -3
View File
@@ -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);
@@ -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);
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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 };
+1 -1
View File
@@ -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<String> = 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());
+1 -1
View File
@@ -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));
+2 -2
View File
@@ -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);
+103 -34
View File
@@ -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<I
if it.is_folder() { Some(it.id) } else { it.parent }
}
/// A thumbnail being rendered off the UI thread: (revision, result slot).
type PendingThumb = (u64, Arc<Mutex<Option<Option<(u32, u32, Vec<u8>)>>>>);
/// 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<egui::TextureHandle> {
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<PendingThumb> = 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::<PendingThumb>(pkey);
});
tex = Some(t);
if *prev == rev {
return tex;
}
}
(w, h, px)
Some(None) => {
ctx.data_mut(|d| d.remove::<PendingThumb>(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<Mutex<Option<Option<(u32, u32, Vec<u8>)>>>> = 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::<PendingThumb>(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<ItemId>, 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<ItemId> = 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::<Option<u64>>(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) {
+1 -1
View File
@@ -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);
+50 -28
View File
@@ -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<Row>, comp: &Comp, closed: &std::collections::BTreeSet<u64>) -> Vec<Row> {
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.
+1 -1
View File
@@ -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;
};
+3 -3
View File
@@ -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,
+4
View File
@@ -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<u64>,
/// 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(),
+5
View File
@@ -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));
}
+169
View File
@@ -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<String> = 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<String> {
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<Rect> = 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<Rect> =
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<Rect> = 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"));
}
+120
View File
@@ -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<u64> = 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<String> = h.state().auto.previous.iter().filter(|e| e.id.starts_with("project.item.")).map(|e| e.id.clone()).collect();
assert!(!shown.is_empty() && drawn(&h) < 80, "{shown:?}");
}
#[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);
}
+27 -2
View File
@@ -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
+55
View File
@@ -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<Comp>` snapshots (`Session::active_comp_arc`,
`Project::comp_arc`) instead of cloning a comp's layers every frame.
- **Virtualised lists.** The Timeline and the Project panel draw (and register automation ids
for) only the rows in view; the Project panel scrolls vertically and reveals an item selected
elsewhere. Menus listing every layer (track matte, parent) are built only while open.
- **Off the UI thread.** Project panel thumbnails render on a background thread (the previous
thumbnail stays up meanwhile); auto-save snapshots the project (an `Arc` clone) and serialises
and writes compact JSON on a background thread (`AutoSaveState::background`, on in the desktop
app; inline on wasm32).
- **Undo is a pointer swap.** History stores `Arc<Project>` snapshots and comps are `Arc`s, so
undo and redo of any edit are constant time. An edit copies the comps it changes (a one-layer
edit in a 5,000-layer comp copies that comp: ~8 ms).
Numbers (Apple Silicon, 14 cores, release build, best of 3–20 runs; the machine was shared with
other builds, load average 150–220, so treat them as orders of magnitude). "Before" is the
commit before M13.14 with the same benchmark code:
| Operation | Before | After |
|---|---|---|
| Startup to first full UI frame (open + app + 2 frames) | 2,119 ms | 432 ms (1,482 ms at load 220) |
| File ▸ Open (125 MB pretty JSON; parse is ~95% of it) | 1,049 ms | 269–733 ms |
| Save (pretty JSON, 125 MB) | 845 ms | 275–359 ms |
| Auto-save, UI-thread cost | 530 ms (pretty, synchronous) | 0.1–0.2 ms (compact, background; 69–172 ms off-thread) |
| Steady frame, Standard workspace | 862 ms | 0.5–6.9 ms |
| Timeline frame scrolling 5,000 layers | 1,725 ms | 1.5–8.1 ms |
| Timeline frame, 500 layers twirled open | 1,508 ms | 1.8–5.5 ms |
| Project panel frame, 503 items, all folders open | 1.2 ms (first screenful only, no scrolling) | 0.4–2.8 ms (scrolling) |
| Small edit (one property) / undo + redo | 14.7 ms / 0.01 ms | 8.4–19.7 ms / 0.01 ms |
| Large edit (switch on 5,000 layers) / undo / redo | 62 / 0.03 / 0.01 ms | 18–57 / 0.02 / 0.01 ms |
| First rendered frame of the 5,000-layer comp at ¼ (viewer thread) | 5.7 s | 2.7–3.6 s (unchanged code; load) |
The before frame times were dominated by an O(layers²) menu build per visible timeline row and
by every panel deep-cloning the active comp each frame. Left for later: per-layer structural
sharing (so a one-layer edit doesn't copy its comp), a binary or compact project format (pretty
JSON parsing is now the bulk of open), and rendering 5,000-layer comps faster.
+7 -1
View File
@@ -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