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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 11:49:55 -04:00
co-authored by Claude Opus 5.5
parent 41f672eef5
commit 8a4a2bfc52
28 changed files with 792 additions and 71 deletions
+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
@@ -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...",
+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);
}
}
+65
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;
@@ -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()
+10
View File
@@ -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");