mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 22:42:24 -06: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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
41f672eef5
commit
8a4a2bfc52
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user