From 41f672eef5f1d4e89114665ee7c3a96d9cce00d3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:59:23 -0400 Subject: [PATCH] M13.14: large-project generator, background compact auto-save Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/autosave.rs | 72 ++++- crates/engine/src/lib.rs | 1 + crates/engine/src/perf.rs | 383 ++++++++++++++++++++++++++ crates/engine/src/session_settings.rs | 46 +++- crates/project/src/lib.rs | 4 + 5 files changed, 495 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/perf.rs diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index c3c1911f..dd55130a 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -25,6 +25,12 @@ const EXT: &str = "ecproj"; /// Auto-save bookkeeping of a session. #[derive(Clone, Debug, Default)] pub struct AutoSaveState { + /// Serialise and write on a background thread (frontends with an event loop turn this on; + /// ignored on wasm32). The UI thread then only picks the slot and snapshots the project + /// (an `Arc` clone), so a large project's auto-save never stalls a frame. + pub background: bool, + /// The background write in flight, if any. + pub pending: Option, /// Wall-clock seconds of the last auto-save (or of the first tick). pub last: Option, /// Slot written last in this session. @@ -37,6 +43,37 @@ pub struct AutoSaveState { pub sentinel: bool, } +/// A background auto-save write ([`AutoSaveState::background`]). +#[derive(Clone)] +pub struct PendingWrite { + pub path: PathBuf, + handle: std::sync::Arc>>>>, +} + +impl std::fmt::Debug for PendingWrite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PendingWrite").field("path", &self.path).finish() + } +} + +impl PendingWrite { + pub fn spawn(path: PathBuf, work: impl FnOnce() -> std::io::Result<()> + Send + 'static) -> std::io::Result { + let h = std::thread::Builder::new().name("autosave".into()).spawn(work)?; + Ok(PendingWrite { path, handle: std::sync::Arc::new(std::sync::Mutex::new(Some(h))) }) + } + pub fn is_finished(&self) -> bool { + self.handle.lock().map(|h| h.as_ref().is_none_or(|h| h.is_finished())).unwrap_or(true) + } + /// Wait for the write; its result (`Ok` if already collected). + pub fn join(&self) -> std::io::Result<()> { + let h = self.handle.lock().ok().and_then(|mut h| h.take()); + match h { + Some(h) => h.join().unwrap_or_else(|_| Err(std::io::Error::other("auto-save thread panicked"))), + None => Ok(()), + } + } +} + /// File-name stem of a project (`/a/Intro.ecproj` → `Intro`; untitled → `Untitled Project`). pub fn project_stem(path: Option<&str>) -> String { path.and_then(|p| Path::new(p).file_stem()).map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled Project".into()) @@ -103,20 +140,41 @@ pub fn write_in( last_slot: Option, json: &str, ) -> std::io::Result<(PathBuf, u32)> { + let plan = plan_in(fs, prefs, project, fallback, last_slot)?; + plan.write(fs, json.as_bytes())?; + Ok((plan.path, plan.slot)) +} + +/// Where the next auto-save goes ([`plan_in`]). +#[derive(Clone, Debug)] +pub struct WritePlan { + pub path: PathBuf, + pub slot: u32, + /// Slots above the (lowered) maximum, removed after the write. + pub stale: Vec, +} + +impl WritePlan { + pub fn write(&self, fs: &dyn FileOps, data: &[u8]) -> std::io::Result<()> { + fs.write(&self.path, data)?; + for p in &self.stale { + let _ = fs.remove(p); + } + Ok(()) + } +} + +/// Pick the next auto-save slot (without writing anything). +pub fn plan_in(fs: &dyn FileOps, prefs: &Prefs, project: Option<&str>, fallback: Option<&Path>, last_slot: Option) -> std::io::Result { let dir = folder(prefs, project, fallback).ok_or_else(|| std::io::Error::other("no auto-save folder for an untitled project"))?; let stem = project_stem(project); let max = prefs.auto_save.max_versions.max(1); let have = existing_in(fs, &dir, &stem); let slot = next_slot(&have, last_slot, max); let path = dir.join(file_name(&stem, slot)); - fs.write(&path, json.as_bytes())?; // Max versions lowered: drop slots above it. - for (n, p, _) in have { - if n > max { - let _ = fs.remove(&p); - } - } - Ok((path, slot)) + let stale = have.into_iter().filter(|(n, _, _)| *n > max).map(|(_, p, _)| p).collect(); + Ok(WritePlan { path, slot, stale }) } /// The newest auto-save of a project, if any. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9213ff8d..e44318d2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod media_browser; pub mod media_cache; pub mod menus; pub mod offload; +pub mod perf; pub mod prefs; pub mod preview; pub mod psd_import; diff --git a/crates/engine/src/perf.rs b/crates/engine/src/perf.rs new file mode 100644 index 00000000..e429491e --- /dev/null +++ b/crates/engine/src/perf.rs @@ -0,0 +1,383 @@ +//! Everyday-operation performance: a large generated project and the engine-side measurements +//! behind `effectcraft-cli bench --ops` (open, save, auto-save, large edits with undo/redo, first +//! frame). The UI-side numbers (first UI frame, timeline and Project panel draw) live in +//! `effectcraft-ui-egui`'s `bench` module. See `docs/architecture.md` ▸ Performance. + +use std::sync::Arc; + +use effectcraft_keyframe::Value as KValue; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{AlphaMode, Comp, Expression, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::Session; + +/// What [`large_project`] builds. +#[derive(Clone, Debug)] +pub struct LargeSpec { + /// Compositions (the main comp, a precomp nesting chain and leaf comps). + pub comps: usize, + /// Layers in the main comp (the timeline stress case). + pub main_layers: usize, + /// Layers in every other comp. + pub comp_layers: usize, + /// Footage items: a third image sequences, a third movies, a third stills. + pub footage: usize, + /// Frames per image sequence. + pub sequence_frames: usize, + /// Depth of the precomp nesting chain under the main comp. + pub nest_depth: usize, + /// Every n-th layer gets expressions (0 = none). + pub expression_every: usize, + /// Folder the footage paths point into (the files need not exist). + pub media_dir: String, +} + +impl Default for LargeSpec { + /// 200 comps, 5,000 layers in the main comp (plus 10 in each other comp), 300 footage items + /// (100 sequences of 240 frames, 100 movies, 100 stills), 20 levels of precomp nesting and + /// expressions on every 4th layer. + fn default() -> Self { + LargeSpec { + comps: 200, + main_layers: 5000, + comp_layers: 10, + footage: 300, + sequence_frames: 240, + nest_depth: 20, + expression_every: 4, + media_dir: "/nonexistent/effectcraft-bench-media".into(), + } + } +} + +impl LargeSpec { + /// A small version for tests. + pub fn small() -> Self { + LargeSpec { comps: 12, main_layers: 300, comp_layers: 4, footage: 30, sequence_frames: 24, nest_depth: 5, expression_every: 3, ..Default::default() } + } + /// Total layers [`large_project`] makes. + pub fn total_layers(&self) -> usize { + self.main_layers + self.comps.saturating_sub(1) * self.comp_layers + } +} + +fn base_comp(name: &str) -> Comp { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": name, "width": 1920, "height": 1080, "frameRate": 30, "duration": 10})).expect("comp.new"); + s.active_comp().expect("comp").clone() +} + +fn footage_item(spec: &LargeSpec, i: usize) -> Footage { + let dir = &spec.media_dir; + let base = Footage { width: 1920, height: 1080, pixel_aspect: 1.0, has_video: true, alpha: AlphaMode::Straight, ..Default::default() }; + match i % 3 { + 0 => { + let frames: Vec = (0..spec.sequence_frames).map(|f| format!("{dir}/seq{i:03}/shot{i:03}_{f:05}.png")).collect(); + Footage { + path: frames.first().cloned().unwrap_or_default(), + kind: FootageKind::Sequence, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(spec.sequence_frames as f64 / 30.0), + codec: "PNG".into(), + sequence: frames, + ..base + } + } + 1 => Footage { + path: format!("{dir}/clip{i:03}.mp4"), + kind: FootageKind::Video, + frame_rate: FrameRate::FPS_30, + duration: Tick::from_seconds_f64(20.0), + has_audio: true, + codec: "H.264".into(), + ..base + }, + _ => Footage { path: format!("{dir}/still{i:03}.png"), kind: FootageKind::Still, codec: "PNG".into(), ..base }, + } +} + +fn set_expr(l: &mut Layer, path: &str, text: &str) { + if let Some(p) = l.props.prop_mut(path) { + p.expr = Some(Expression { text: text.into(), enabled: true }); + } +} + +/// One layer of a varied mix: solids, footage, text, shapes, nulls, some with an effect, some +/// with expressions, parented to the layer above every 7th. +fn make_layer(p: &mut Project, comp: &Comp, spec: &LargeSpec, n: usize, solid: ItemId, footage: &[ItemId], prev: Option<&Layer>) -> Layer { + let (src, size) = match n % 5 { + 0 => (LayerSource::Solid { item: solid }, (1920, 1080)), + 1 if !footage.is_empty() => (LayerSource::Footage { item: footage[n % footage.len()] }, (1920, 1080)), + 2 => (LayerSource::Text, (0, 0)), + 3 => (LayerSource::Shape, (0, 0)), + _ => (LayerSource::Null, (100, 100)), + }; + let mut l = build::layer(p, comp, &format!("Layer {n}"), src, size, None); + if n.is_multiple_of(6) + && let Some(fx) = crate::effects::find("ec.blur.gaussian") + { + let mut next = p.next_id; + let mut g = crate::effects::instantiate(fx, &mut Ids(&mut next), fx.name, [1920.0, 1080.0]); + p.next_id = next; + if let Some(b) = g.prop_mut("blurriness") { + b.value = KValue::Scalar(4.0); + } + if let Some(e) = l.props.sub_mut("effects") { + e.children.push(g.into()); + } + } + if spec.expression_every > 0 && n.is_multiple_of(spec.expression_every) { + set_expr(&mut l, "transform/position", "wiggle(2, 20)"); + set_expr(&mut l, "transform/rotation", "time * 30 + index"); + set_expr(&mut l, "transform/opacity", "50 + 50 * Math.sin(time * 2)"); + } + if n.is_multiple_of(7) + && let Some(prev) = prev + { + l.parent = Some(prev.id); + } + // Stagger the bars so the timeline isn't one block. + let span = comp.duration.0.max(1); + let off = (n as i64 * 7919) % span; + l.in_point = Tick(off / 2); + l +} + +/// A large project for performance work (see [`LargeSpec`]). The main comp is named `Main` and +/// is the first comp; the nesting chain is `Nest 1` (inside Main) … `Nest {depth}`. +pub fn large_project(spec: &LargeSpec) -> Project { + let mut p = Project::default(); + let template = base_comp("Main"); + let media = p.add_item("Media", crate::color::Label::None, None, ItemKind::Folder); + let comps_folder = p.add_item("Comps", crate::color::Label::None, None, ItemKind::Folder); + let footage: Vec = (0..spec.footage) + .map(|i| { + let f = footage_item(spec, i); + let name = std::path::Path::new(&f.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + p.add_item(&name, crate::color::Label::None, Some(media), ItemKind::Footage(f)) + }) + .collect(); + let solid = p.add_item( + "Bench Solid", + crate::color::Label::None, + Some(media), + ItemKind::Solid(Solid { color: [0.2, 0.4, 0.8], width: 1920, height: 1080, pixel_aspect: 1.0 }), + ); + + // Leaf comps and the nesting chain are built bottom-up so each precomp layer can point at + // an existing item. + let mut n = 0usize; + let fill = |p: &mut Project, comp: &mut Comp, count: usize, n: &mut usize| { + let mut layers: Vec = Vec::with_capacity(count); + for _ in 0..count { + let l = make_layer(p, comp, spec, *n, solid, &footage, layers.last()); + *n += 1; + layers.push(l); + } + comp.layers = layers; + }; + let others = spec.comps.saturating_sub(1); + let depth = spec.nest_depth.min(others); + // Nesting chain: Nest {depth} (deepest) … Nest 1. + let mut below: Option = None; + for d in (1..=depth).rev() { + let mut c = template.clone(); + fill(&mut p, &mut c, spec.comp_layers.saturating_sub(1), &mut n); + if let Some(b) = below { + let l = build::layer(&mut p, &c, &format!("Nest {}", d + 1), LayerSource::Comp { item: b }, (1920, 1080), None); + c.layers.insert(0, l); + } + below = Some(p.add_item(&format!("Nest {d}"), crate::color::Label::None, Some(comps_folder), ItemKind::Comp(Arc::new(c)))); + } + let mut leaves = vec![]; + for i in depth..others { + let mut c = template.clone(); + fill(&mut p, &mut c, spec.comp_layers, &mut n); + leaves.push(p.add_item(&format!("Shot {:03}", i + 1), crate::color::Label::None, Some(comps_folder), ItemKind::Comp(Arc::new(c)))); + } + // Main: the chain's top, a few leaf precomps, then the bulk. + let mut main = template; + let mut layers = vec![]; + if let Some(b) = below { + layers.push(build::layer(&mut p, &main, "Nest 1", LayerSource::Comp { item: b }, (1920, 1080), None)); + } + for (k, leaf) in leaves.iter().take(spec.main_layers.saturating_sub(1) / 10).enumerate() { + layers.push(build::layer(&mut p, &main, &format!("Shot {:03}", k + 1), LayerSource::Comp { item: *leaf }, (1920, 1080), None)); + } + while layers.len() < spec.main_layers { + let l = make_layer(&mut p, &main, spec, n, solid, &footage, layers.last()); + n += 1; + layers.push(l); + } + main.layers = layers; + p.add_item("Main", crate::color::Label::None, None, ItemKind::Comp(Arc::new(main))); + // `Main` must be the first comp `replace_project` opens: give it the smallest id. + let main_id = p.items.values().find(|i| i.name == "Main").map(|i| i.id).expect("main"); + let first = p.items.keys().next().copied().expect("items"); + if first != main_id { + let item = p.items.remove(&main_id).expect("main"); + let other = p.items.remove(&first).expect("first"); + // Swap ids: references to `first` (the Media folder) are the footage/solid parents. + p.items.insert(first, effectcraft_project::Item { id: first, ..item }); + p.items.insert(main_id, effectcraft_project::Item { id: main_id, ..other }); + for it in p.items.values_mut() { + if it.parent == Some(first) { + it.parent = Some(main_id); + } + } + } + p.fix_next_id(); + p +} + +/// One measurement. +#[derive(Clone, Debug)] +pub struct Measure { + pub name: &'static str, + pub ms: f64, + pub note: String, +} + +impl Measure { + pub fn json(&self) -> Value { + json!({"name": self.name, "ms": (self.ms * 1000.0).round() / 1000.0, "note": self.note}) + } +} + +/// Time `f` (best of `runs`). +pub fn time_best(runs: usize, mut f: impl FnMut() -> T) -> (f64, T) { + let mut best = f64::INFINITY; + let mut out = None; + for _ in 0..runs.max(1) { + let t0 = web_time::Instant::now(); + let r = f(); + best = best.min(t0.elapsed().as_secs_f64() * 1000.0); + out = Some(r); + } + (best, out.expect("ran")) +} + +/// The engine-side everyday operations on a large project: build, save, open (into a session +/// from `fresh`), first frame, small and large edits with undo/redo, auto-save. Files go into +/// `dir`. Returns the measurements and the opened session (for the UI-side numbers). +pub fn ops_bench(spec: &LargeSpec, dir: &std::path::Path, fresh: &dyn Fn() -> Session) -> (Vec, Session) { + let mut out = vec![]; + let (ms, project) = time_best(1, || large_project(spec)); + let comps = project.comps().count(); + let layers: usize = project.comps().map(|(_, c)| c.layers.len()).sum(); + out.push(Measure { name: "generate", ms, note: format!("{comps} comps, {layers} layers, {} items", project.items.len()) }); + + let path = dir.join("bench-large.ecproj"); + let path_s = path.to_string_lossy().to_string(); + let mut s = fresh(); + s.replace_project(project, None); + let (ms, r) = time_best(3, || s.execute("file.saveAs", json!({"path": path_s})).expect("save")); + out.push(Measure { name: "save", ms, note: format!("{} bytes", r["bytes"]) }); + + // Open: what File ▸ Open does, into a fresh session. + let (ms, mut s) = time_best(3, || { + let mut s = fresh(); + s.execute("file.open", json!({"path": path_s})).expect("open"); + s + }); + out.push(Measure { name: "open", ms, note: format!("active comp {:?}", s.state.active_comp.map(|c| c.0)) }); + + // First frame of the main comp at 1/4 resolution (the viewer's first request). + let cid = s.active_comp_id().expect("main comp"); + let (ms, _) = time_best(1, || { + let opts = crate::render::RenderOpts { scale: 0.25, ..Default::default() }; + s.render(cid, Tick::ZERO, opts) + }); + out.push(Measure { name: "firstFrame", ms, note: "Main at 1/4, cold".into() }); + + // A small edit in the big project (one property on one layer): the everyday cost of the + // undo snapshot. + let first = s.active_comp().and_then(|c| c.layers.get(10)).map(|l| l.id.0).expect("layer"); + let mut v = 0.0; + let (ms, _) = time_best(20, || { + v += 1.0; + s.execute("prop.set", json!({"layer": first, "path": "transform/opacity", "value": 50.0 + v})).expect("prop.set") + }); + out.push(Measure { name: "smallEdit", ms, note: "prop.set on one layer of Main".into() }); + let (ms, _) = time_best(5, || { + s.undo(); + s.redo(); + }); + out.push(Measure { name: "smallUndoRedo", ms, note: "one undo + one redo".into() }); + + // A large edit: every layer of Main at once. + let all: Vec = s.active_comp().map(|c| c.layers.iter().map(|l| l.id.0).collect()).unwrap_or_default(); + let mut on = false; + let (ms, _) = time_best(3, || { + on = !on; + s.execute("layer.setSwitch", json!({"layers": all, "switch": "shy", "value": on})).expect("setSwitch") + }); + out.push(Measure { name: "largeEdit", ms, note: format!("shy switch on {} layers", all.len()) }); + let (ms, _) = time_best(3, || s.undo()); + out.push(Measure { name: "largeUndo", ms, note: String::new() }); + let (ms, _) = time_best(3, || s.redo()); + out.push(Measure { name: "largeRedo", ms, note: String::new() }); + + // Auto-save: the full cost and what the UI thread pays. + let auto = dir.join("autosave"); + let _ = s.execute("prefs.set", json!({"values": {"autoSave.location": "custom", "autoSave.folder": auto.to_string_lossy()}})); + s.path = Some(path_s.clone()); + let (ms, _) = time_best(3, || s.autosave_now().expect("autosave")); + out.push(Measure { name: "autosaveSync", ms, note: "serialise + write on the calling thread".into() }); + s.autosave.background = true; + let (ms, _) = time_best(3, || s.autosave_now().expect("autosave")); + out.push(Measure { name: "autosaveUiThread", ms, note: "background auto-save: UI-thread share".into() }); + s.autosave.background = false; + let _ = s.autosave_wait(); + (out, s) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn large_project_round_trips_and_opens_main() { + let spec = LargeSpec::small(); + let p = large_project(&spec); + assert_eq!(p.comps().count(), spec.comps); + let layers: usize = p.comps().map(|(_, c)| c.layers.len()).sum(); + assert!(layers >= spec.total_layers() - spec.comps, "{layers}"); + let footage = p.items.values().filter(|i| matches!(i.kind, ItemKind::Footage(_))).count(); + assert_eq!(footage, spec.footage); + let back = Project::from_json(&p.to_json()).unwrap(); + assert_eq!(back, p); + let mut s = Session::default(); + s.replace_project(p, None); + assert_eq!(s.active_comp().map(|c| c.layers.len()), Some(spec.main_layers)); + // The nesting chain is `nest_depth` deep. + let mut depth = 0; + let mut cur = s.project.items.values().find(|i| i.name == "Nest 1").map(|i| i.id); + while let Some(c) = cur { + depth += 1; + cur = s.project.comp(c).and_then(|c| { + c.layers.iter().find_map(|l| match l.source { + LayerSource::Comp { item } => Some(item), + _ => None, + }) + }); + } + assert_eq!(depth, spec.nest_depth); + } + + #[test] + fn ops_bench_runs_on_a_small_project() { + let dir = std::env::temp_dir().join(format!("ec-ops-bench-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (m, s) = ops_bench(&LargeSpec::small(), &dir, &Session::default); + let names: Vec<&str> = m.iter().map(|m| m.name).collect(); + for want in ["generate", "save", "open", "firstFrame", "smallEdit", "largeEdit", "largeUndo", "largeRedo", "autosaveSync", "autosaveUiThread"] { + assert!(names.contains(&want), "{want}"); + } + assert!(m.iter().all(|m| m.ms.is_finite() && m.ms >= 0.0)); + assert!(s.autosave.last_path.as_deref().is_some_and(|p| std::path::Path::new(p).is_file())); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index ecb7f44a..e19d0701 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -163,7 +163,9 @@ impl Session { pub(crate) fn note_project_path(&mut self, path: &str) { self.prefs.push_recent(path); self.save_prefs(); - self.autosave = autosave::AutoSaveState { last: self.autosave.last, sentinel: self.autosave.sentinel, ..Default::default() }; + let _ = self.autosave_wait(); + self.autosave = + autosave::AutoSaveState { last: self.autosave.last, sentinel: self.autosave.sentinel, background: self.autosave.background, ..Default::default() }; self.update_sentinel(); } @@ -191,11 +193,34 @@ impl Session { } /// Write an auto-save now (whether or not the project is dirty). Returns its path. + /// + /// With [`autosave::AutoSaveState::background`] the project is serialised and written on a + /// background thread (the path is returned at once; [`Session::autosave_wait`] waits). + /// Auto-saves are compact JSON (opened like any project); File ▸ Save writes pretty JSON. pub fn autosave_now(&mut self) -> Result { - let json = self.project.to_json(); + // One write at a time: the previous one must land before its slot rotates. + self.autosave_wait()?; let root = self.default_autosave_root(); - let (path, slot) = autosave::write_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot, &json) - .map_err(|e| EngineError::Other(format!("auto-save failed: {e}")))?; + let fail = |e: std::io::Error| EngineError::Other(format!("auto-save failed: {e}")); + let plan = autosave::plan_in(self.file_ops(), &self.prefs, self.path.as_deref(), root.as_deref(), self.autosave.last_slot).map_err(fail)?; + if self.autosave.background && !cfg!(target_arch = "wasm32") { + let project = self.project.clone(); + let config = self.config.clone(); + let job = plan.clone(); + let pending = autosave::PendingWrite::spawn(plan.path.clone(), move || { + let json = project.to_json_compact(); + match &config { + Some(c) => job.write(c.files(), json.as_bytes()), + None => job.write(&crate::config::StdFiles, json.as_bytes()), + } + }) + .map_err(fail)?; + self.autosave.pending = Some(pending); + } else { + let json = self.project.to_json_compact(); + plan.write(self.file_ops(), json.as_bytes()).map_err(fail)?; + } + let (path, slot) = (plan.path, plan.slot); let p = path.to_string_lossy().to_string(); self.autosave.last_slot = Some(slot); self.autosave.saved_revision = Some(self.revision); @@ -204,9 +229,22 @@ impl Session { Ok(p) } + /// Wait for a background auto-save ([`autosave::AutoSaveState::background`]) to land. + pub fn autosave_wait(&mut self) -> Result<()> { + match self.autosave.pending.take() { + Some(w) => w.join().map_err(|e| EngineError::Other(format!("auto-save failed: {e}"))), + None => Ok(()), + } + } + /// Call periodically with wall-clock seconds: auto-saves a dirty project once the interval /// has passed since the last auto-save (or since the first call). Returns the written path. pub fn autosave_tick(&mut self, now: f64) -> Option> { + if self.autosave.pending.as_ref().is_some_and(autosave::PendingWrite::is_finished) + && let Err(e) = self.autosave_wait() + { + return Some(Err(e)); + } let a = &self.prefs.auto_save; if !a.enabled { self.autosave.last = Some(now); diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index c651165e..da85a844 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1042,6 +1042,10 @@ impl Project { pub fn to_json(&self) -> String { serde_json::to_string_pretty(self).unwrap_or_default() } + /// [`Project::to_json`] without indentation: smaller and faster (auto-saves). + pub fn to_json_compact(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } pub fn from_json(s: &str) -> Result { let mut p: Project = serde_json::from_str(s).map_err(|e| ProjectError::Invalid(e.to_string()))?; if p.schema > SCHEMA_VERSION {