mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M13.14: lazy project open, non-blocking UI with large projects, bench --ops
- footage.check: background footage check after File > Open (Progress panel), not an undo step - timeline: track-matte / parent menus built only while open (was O(n^2) per frame); expression-row lookup per layer; panels hold Arc<Comp> snapshots instead of deep clones - Project panel: virtualised vertical scrolling, selection reveal, thumbnails rendered off the UI thread - effectcraft-cli bench --ops (engine::perf + ui-egui::bench) Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
41f672eef5
commit
8a4a2bfc52
28 files changed
+792
-71
No files matched your search
Generated
+1
@@ -1175,6 +1175,7 @@ dependencies = [
|
||||
"effectcraft-host",
|
||||
"effectcraft-render",
|
||||
"effectcraft-time",
|
||||
"effectcraft-ui-egui",
|
||||
"image",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] render timings
|
||||
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
|
||||
//! effectcraft-cli bench --ops [--small] [--layers N] [--comps N] [--footage N] everyday-operation timings
|
||||
//! on a large generated project (open, save, auto-save, undo/redo, timeline, Project panel)
|
||||
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
|
||||
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
|
||||
//!
|
||||
@@ -49,6 +51,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] 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
|
||||
@@ -60,6 +65,9 @@ options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge
|
||||
const VALUED: &[&str] = &[
|
||||
"--n",
|
||||
"--play",
|
||||
"--layers",
|
||||
"--comps",
|
||||
"--footage",
|
||||
"--params",
|
||||
"--project",
|
||||
"--bridge",
|
||||
@@ -615,6 +623,9 @@ fn list_commands_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
/// `bench`: time one frame N times without the layer cache (per-layer and per-effect breakdown),
|
||||
/// and optionally `--play N` consecutive frames with and without the cache.
|
||||
fn bench_cmd(args: &Args) -> Result<(), Failure> {
|
||||
if args.flag("--ops") {
|
||||
return bench_ops(args);
|
||||
}
|
||||
let mut s = effectcraft_host::session();
|
||||
match &args.project {
|
||||
Some(p) => s.execute("file.open", json!({"path": p})),
|
||||
@@ -637,6 +648,50 @@ fn bench_cmd(args: &Args) -> Result<(), Failure> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `bench --ops`: everyday operations on a large generated project (200 comps, 5,000 layers in
|
||||
/// the main comp, 300 footage items, 20-deep precomp nesting, expressions): startup to first
|
||||
/// frame, open, save, auto-save, small and large edits with undo/redo, first frame, timeline
|
||||
/// scrolling and the Project panel. `--small` for a quick run; `--layers N`, `--comps N`,
|
||||
/// `--footage N` resize it. See docs/architecture.md ▸ Performance.
|
||||
fn bench_ops(args: &Args) -> Result<(), Failure> {
|
||||
use effectcraft_engine::perf::{self, LargeSpec};
|
||||
let mut spec = if args.flag("--small") { LargeSpec::small() } else { LargeSpec::default() };
|
||||
if let Some(n) = args.num("--layers")? {
|
||||
spec.main_layers = n.max(1.0) as usize;
|
||||
}
|
||||
if let Some(n) = args.num("--comps")? {
|
||||
spec.comps = n.max(1.0) as usize;
|
||||
}
|
||||
if let Some(n) = args.num("--footage")? {
|
||||
spec.footage = n.max(0.0) as usize;
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("effectcraft-bench-ops-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).map_err(|e| Failure::Error(format!("{}: {e}", dir.display())))?;
|
||||
let t0 = std::time::Instant::now();
|
||||
drop(effectcraft_host::session());
|
||||
let host_ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let (mut m, _) = perf::ops_bench(&spec, &dir, &effectcraft_host::session);
|
||||
m.insert(0, perf::Measure { name: "hostSession", ms: host_ms, note: "a fully wired session (media, expressions, scripting, export)".into() });
|
||||
let path = dir.join("bench-large.ecproj").to_string_lossy().to_string();
|
||||
m.extend(effectcraft_ui_egui::bench::ui_ops(|| {
|
||||
let mut s = effectcraft_host::session();
|
||||
if let Err(e) = s.execute("file.open", json!({"path": path})) {
|
||||
eprintln!("bench --ops: {e}");
|
||||
}
|
||||
s
|
||||
}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if args.flag("--json") {
|
||||
emit(&json!({"spec": format!("{spec:?}"), "measures": m.iter().map(perf::Measure::json).collect::<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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -823,6 +823,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...",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod camera_track;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod demo;
|
||||
pub mod footage_check;
|
||||
pub mod history;
|
||||
pub mod jobs;
|
||||
pub mod learn;
|
||||
@@ -85,6 +86,10 @@ pub type Result<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.
|
||||
@@ -367,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> {
|
||||
@@ -438,6 +450,7 @@ impl Default for Session {
|
||||
job_log: vec![],
|
||||
next_task_id: 1,
|
||||
learn: None,
|
||||
check_footage_on_open: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -582,6 +595,10 @@ impl Session {
|
||||
pub fn active_comp(&self) -> Option<&Comp> {
|
||||
self.project.comp(self.state.active_comp?)
|
||||
}
|
||||
/// [`Session::active_comp`] as a shared snapshot (an `Arc` clone, not a deep copy).
|
||||
pub fn active_comp_arc(&self) -> Option<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())
|
||||
}
|
||||
@@ -713,6 +730,54 @@ impl Session {
|
||||
Ok((img.width, img.height, img.to_rgba8_over(c.background)))
|
||||
}
|
||||
|
||||
/// A Project panel thumbnail of an item (a comp at `t`, footage at its first frame), longest
|
||||
/// side at most `max_side`, as a job that owns everything it reads: frontends run it off the
|
||||
/// UI thread (a big comp takes far longer than a frame). `None` for items without pixels.
|
||||
pub fn thumbnail_job(&self, item: ItemId, t: Tick, max_side: u32) -> Option<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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the whole project (open/new), resetting history and state.
|
||||
pub fn replace_project(&mut self, mut p: Project, path: Option<String>) {
|
||||
if self.stop_render()
|
||||
|
||||
@@ -283,6 +283,16 @@ pub fn ops_bench(spec: &LargeSpec, dir: &std::path::Path, fresh: &dyn Fn() -> Se
|
||||
s
|
||||
});
|
||||
out.push(Measure { name: "open", ms, note: format!("active comp {:?}", s.state.active_comp.map(|c| c.0)) });
|
||||
// Its parts: reading the file and parsing it (the rest is replacing the session's project).
|
||||
let (ms, text) = time_best(3, || std::fs::read_to_string(&path).unwrap_or_default());
|
||||
out.push(Measure { name: "openRead", ms, note: format!("{} MB", text.len() / 1_000_000) });
|
||||
let (ms, _) = time_best(3, || Project::from_json(&text).expect("parse"));
|
||||
out.push(Measure { name: "openParse", ms, note: "JSON → Project".into() });
|
||||
let compact = Project::from_json(&text).expect("parse").to_json_compact();
|
||||
drop(text);
|
||||
let (ms, _) = time_best(3, || Project::from_json(&compact).expect("parse"));
|
||||
out.push(Measure { name: "openParseCompact", ms, note: format!("the same as compact JSON ({} MB, as auto-saves are written)", compact.len() / 1_000_000) });
|
||||
drop(compact);
|
||||
|
||||
// First frame of the main comp at 1/4 resolution (the viewer's first request).
|
||||
let cid = s.active_comp_id().expect("main comp");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -395,7 +395,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
|
||||
/// Revert and Push to Comp.
|
||||
fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let Some(comp) = app.session.active_comp().cloned() else { return };
|
||||
let Some(comp) = app.session.active_comp_arc() else { return };
|
||||
let Some(layer) = app.session.state.selected_layers.iter().filter_map(|l| comp.layer(*l)).find(|l| essential::group(l).is_some()).cloned() else { return };
|
||||
let LayerSource::Comp { item: src } = layer.source else { return };
|
||||
let Some(eg) = app.session.project.comp(src).and_then(|c| c.essential.clone()) else { return };
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -731,7 +739,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
|
||||
return;
|
||||
};
|
||||
let Some(comp) = app.session.project.comp(cid).cloned() else { return };
|
||||
let Some(comp) = app.session.project.comp_arc(cid) else { return };
|
||||
let p = ui.painter().clone();
|
||||
let time = app.session.time();
|
||||
let fr = comp.frame_rate;
|
||||
@@ -1294,14 +1302,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if tresp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
// The menu lists every layer: built only while it is open (per visible row
|
||||
// it made a big comp's timeline quadratic).
|
||||
let mut opts = vec!["No Track Matte".to_string(), "-".to_string()];
|
||||
let candidates: Vec<&Layer> = comp.layers.iter().filter(|l| l.id != layer.id && l.source.is_av()).collect();
|
||||
for l in &candidates {
|
||||
opts.push(format!("{}. {}", idx_of(l.id), l.name));
|
||||
}
|
||||
opts.push("-".into());
|
||||
for k in MatteKind::ALL {
|
||||
opts.push(k.label().into());
|
||||
let mut candidates: Vec<&Layer> = vec![];
|
||||
if widgets::popup_is_open(ui, pop) {
|
||||
for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id && l.source.is_av()) {
|
||||
opts.push(format!("{}. {}", i + 1, l.name));
|
||||
candidates.push(l);
|
||||
}
|
||||
opts.push("-".into());
|
||||
for k in MatteKind::ALL {
|
||||
opts.push(k.label().into());
|
||||
}
|
||||
}
|
||||
if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) {
|
||||
if i == 0 {
|
||||
@@ -1340,9 +1353,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if presp.clicked() {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
let cands: Vec<&Layer> = comp.layers.iter().filter(|l| l.id != layer.id).collect();
|
||||
let mut cands: Vec<&Layer> = vec![];
|
||||
let mut popts = vec!["None".to_string(), "-".to_string()];
|
||||
popts.extend(cands.iter().map(|l| format!("{}. {}", idx_of(l.id), l.name)));
|
||||
if widgets::popup_is_open(ui, pop) {
|
||||
for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id) {
|
||||
popts.push(format!("{}. {}", i + 1, l.name));
|
||||
cands.push(l);
|
||||
}
|
||||
}
|
||||
if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) {
|
||||
let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) };
|
||||
actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par})));
|
||||
@@ -1839,7 +1857,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Alt-drag the first or last key of a selected group: scale the group in time.
|
||||
if kresp.drag_started()
|
||||
&& ui.input(|i| i.modifiers.alt)
|
||||
&& let Some(c) = app.session.active_comp().cloned()
|
||||
&& let Some(c) = app.session.active_comp_arc()
|
||||
{
|
||||
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
|
||||
}
|
||||
@@ -1889,7 +1907,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
// Alt-drag key group scaling (follows the pointer until release).
|
||||
if let Some(c) = app.session.active_comp().cloned() {
|
||||
if let Some(c) = app.session.active_comp_arc() {
|
||||
super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut actions);
|
||||
}
|
||||
// Graph editor.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -327,7 +327,7 @@ fn aux_views(
|
||||
/// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle.
|
||||
fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect {
|
||||
let Some(lv) = app.session.state.locked_viewer else { return full };
|
||||
let Some(comp) = app.session.project.comp(lv.comp).cloned() else { return full };
|
||||
let Some(comp) = app.session.project.comp_arc(lv.comp) else { return full };
|
||||
let name = app.session.project.item(lv.comp).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let gap = 2.0;
|
||||
let mid = full.center().x;
|
||||
@@ -410,7 +410,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
empty_state(app, ui, rect);
|
||||
return;
|
||||
};
|
||||
let comp = app.session.project.comp(cid).cloned().unwrap_or_else(|| effectcraft_engine::project::Comp::new(1920, 1080, Default::default(), Tick::ZERO));
|
||||
let comp = app.session.project.comp_arc(cid).unwrap_or_else(|| effectcraft_engine::project::Comp::new(1920, 1080, Default::default(), Tick::ZERO).into());
|
||||
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let p = ui.painter().clone();
|
||||
VIEW_CAM.with(|c| c.set(app.session.view_camera(cid)));
|
||||
@@ -1628,7 +1628,7 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
|
||||
&& matches!(kind, "rect" | "ellipse")
|
||||
{
|
||||
// Mask in layer space: invert the layer transform.
|
||||
let comp = app.session.active_comp().cloned();
|
||||
let comp = app.session.active_comp_arc();
|
||||
if let Some(comp) = comp {
|
||||
let ectx = EvalCtx {
|
||||
project: &app.session.project,
|
||||
|
||||
@@ -561,6 +561,9 @@ pub struct UiState {
|
||||
/// Horizontal scroll of the Project panel's optional columns (Name and Label stay put).
|
||||
#[serde(default)]
|
||||
pub project_hscroll: f32,
|
||||
/// Vertical scroll of the Project panel's item list (points); only visible rows are drawn.
|
||||
#[serde(default)]
|
||||
pub project_scroll: f32,
|
||||
/// Effect Controls twirl state (group uids that are collapsed).
|
||||
pub fx_closed: BTreeSet<u64>,
|
||||
/// Slider params whose slider row is twirled open (AE hides sliders by default).
|
||||
@@ -652,6 +655,7 @@ impl Default for UiState {
|
||||
project_sort: default_project_sort(),
|
||||
project_sort_desc: false,
|
||||
project_hscroll: 0.0,
|
||||
project_scroll: 0.0,
|
||||
project_columns: default_project_columns(),
|
||||
fx_closed: BTreeSet::new(),
|
||||
fx_slider_open: BTreeSet::new(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! M13.14: the UI with a large project. The Project panel scrolls (only the rows in view are
|
||||
//! drawn), reveals an item selected elsewhere and renders thumbnails off the UI thread; the
|
||||
//! Timeline reaches the last of thousands of layers; the Progress panel lists the footage check
|
||||
//! started by a lazy open.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::perf::{LargeSpec, large_project};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn spec() -> LargeSpec {
|
||||
LargeSpec { comps: 8, main_layers: 1500, comp_layers: 3, footage: 90, sequence_frames: 12, nest_depth: 3, expression_every: 5, ..Default::default() }
|
||||
}
|
||||
|
||||
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn large() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.replace_project(large_project(&spec()), None);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_panel_scrolls_and_reveals_the_selection() {
|
||||
let mut h = harness(large());
|
||||
let folders: Vec<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 last = h.state().session.project.items.values().filter(|i| i.parent.is_some()).map(|i| i.id.0).max().unwrap();
|
||||
let drawn = |h: &Harness<'_, EffectcraftApp>| h.state().auto.previous.iter().filter(|e| e.id.starts_with("project.item.") && e.id.matches('.').count() == 2).count();
|
||||
let rows = drawn(&h);
|
||||
assert!(rows > 5 && rows < 80, "only the rows in view are drawn: {rows}");
|
||||
// Selecting an item (a command, an agent) scrolls it into view.
|
||||
let footage = h.state().session.project.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Footage(_))).map(|i| i.id.0).max().unwrap();
|
||||
h.state_mut().session.execute("project.select", json!({"items": [footage]})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find(&format!("project.item.{footage}")).is_some(), "selected item scrolled into view");
|
||||
assert!(h.state().ui.project_scroll > 0.0);
|
||||
// Scrolling to the end shows the last rows.
|
||||
h.state_mut().ui.project_scroll = 1.0e6;
|
||||
h.run_steps(2);
|
||||
let shown: Vec<String> = h.state().auto.previous.iter().filter(|e| e.id.starts_with("project.item.")).map(|e| e.id.clone()).collect();
|
||||
assert!(!shown.is_empty() && drawn(&h) < 80, "{shown:?}");
|
||||
let _ = last;
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
+28
-2
@@ -47,14 +47,18 @@ The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MC
|
||||
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands {filter?, enabled_only?}` | Discover command ids, their param docs, and whether each can run now. |
|
||||
| `execute_command {command, params?}` | Run any command (undoable). |
|
||||
| `list_commands {filter?, enabled_only?, schemas?}` | Discover command ids, their param docs (and, with `schemas`, a JSON Schema each), and whether each can run now. |
|
||||
| `describe_command {command}` | One command in full: label, menu, shortcut, params doc, JSON `schema`, enabled / `why`. |
|
||||
| `execute_command {command, params?}` | Run any command (undoable). Unknown parameter keys are rejected with the accepted list. |
|
||||
| `get_state` | Editor state (active comp, time, selections, tool) plus `app`: version, command and effect counts, export formats, parity summary. |
|
||||
| `run_script {code, name?}` | Run JavaScript with the After Effects-style scripting object model (`app.project`, `comp.layers.addText(…)`, `layer.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(…)`…). Returns `{ok, result, output, error: {message, line, column}}`; edits are undoable. |
|
||||
| `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. |
|
||||
| `get_layer {layer, comp?, time?, flat?}` | A layer's property tree. Every node has a `path`. |
|
||||
| `get_property {layer, path, comp?, time?}` | Value at a time, keyframes and expression. |
|
||||
| `set_property {layer, path, value?, time?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe; with `expression` it sets an expression. |
|
||||
| `add_keyframe {layer, path, time+value \| keys:[...], interpolation?}` | Adds keys, then optionally applies linear/bezier/hold/easyEase. |
|
||||
| `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. |
|
||||
| `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call; returns the instance path (`effects/#n`) and its parameter paths. |
|
||||
| `render_frame {comp?, time?, max_side?, path?, inline?}` | Returns a PNG image of a frame. |
|
||||
| `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. |
|
||||
| `undo {steps?}` / `redo {steps?}` | History. |
|
||||
@@ -78,6 +82,27 @@ instead of being ignored.
|
||||
4. `execute_command {"command":"effect.apply","params":{"layer":2,"effect":"Gaussian Blur"}}`, then `set_property {"layer":2,"path":"effects/#1/blurriness","value":8}`
|
||||
5. `render_frame {"time":1.0}` lets you look at the result, and `save_project {"path":"intro.ecproj"}` saves it.
|
||||
|
||||
### Cookbook
|
||||
|
||||
Short recipes; every step is one tool call.
|
||||
|
||||
| Goal | Calls |
|
||||
|---|---|
|
||||
| Orient yourself | `get_state` → `get_project` → `get_comp {}` |
|
||||
| Find the right command | `list_commands {"filter":"mask"}` → `describe_command {"command":"mask.new"}` (params + JSON Schema) |
|
||||
| Title card | `execute_command comp.new {...}` → `execute_command layer.newText {"text":"Hi","size":120}` → `render_frame {"time":0}` |
|
||||
| Animate | `add_keyframe {"layer":2,"path":"transform/scale","keys":[{"time":0,"value":[0,0]},{"time":0.5,"value":[100,100]}],"interpolation":"easyEase"}` |
|
||||
| Effect with settings | `list_effects {"filter":"glow"}` → `add_effect {"layer":2,"effect":"Glow","values":{"threshold":40,"radius":30}}` → `set_property {"layer":2,"path":"effects/#1/intensity","value":2}` |
|
||||
| Drive with an expression | `set_property {"layer":2,"path":"transform/rotation","expression":"time*90"}` |
|
||||
| Inspect a layer | `get_layer {"layer":2,"flat":true}` (every property with its `path`, value, key count) |
|
||||
| Check the result | `render_frame {"time":1.5,"max_side":640}`; in bridge mode `screenshot {"panel":"Timeline"}` |
|
||||
| Undo a mistake | `undo {}`, or `history {}` then `history {"goto": n}` |
|
||||
| Missing media after opening | `execute_command footage.check {"wait":true}` → `{checked, missing, probed}`; then `file.findMissing {"what":"footage"}` |
|
||||
| Save | `save_project {"path":"out.ecproj"}` |
|
||||
|
||||
The desktop app checks footage in the background after every open (a "Checking footage" job in
|
||||
the Progress panel, `jobs.list`); headless sessions run `footage.check` when they want it.
|
||||
|
||||
### Motion tracking
|
||||
|
||||
Trackers live on the tracked layer (`Motion Trackers ▸ Tracker n ▸ Track Point n`) and are driven
|
||||
@@ -273,6 +298,7 @@ on stdout. Errors print `{"error": ...}` and exit with status 1; usage errors ex
|
||||
```sh
|
||||
effectcraft-cli info --json
|
||||
effectcraft-cli commands --filter keys
|
||||
effectcraft-cli exec --list --schemas --json # every command with its params JSON Schema
|
||||
effectcraft-cli exec comp.new --params '{"name":"Main","width":1280,"height":720}' --empty --save-as main.ecproj
|
||||
effectcraft-cli exec layer.newSolid '{"color":"#3366ff"}' main.ecproj --save
|
||||
effectcraft-cli props Main '#1' --project main.ecproj # flat property list with paths
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user