diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index cd2d240e..a6c80e74 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -7,7 +7,8 @@ //! out to the frame), so it lives in a process-wide content-keyed cache ([`cached`]) that the //! engine's propagation job fills at full resolution and rendering fills on demand (reading //! neighbouring frames with [`crate::EffectHost::self_at`]). Undo, redo and reopening a project -//! therefore never see stale mattes. **Freeze** stores the final mattes in the hidden **Frozen** +//! therefore never see stale mattes. Behind the memory cache an optional [`SegStore`] (the +//! persistent disk cache, set by the engine) keeps computed segmentations across restarts. **Freeze** stores the final mattes in the hidden **Frozen** //! parameter ([`FrozenCache`]); they are used while their key still matches. //! //! Rendering: binary segmentation → Roto Brush Matte (Shift Edge, Reduce Chatter, Feather, @@ -23,21 +24,21 @@ //! higher working resolution with more iterations. use std::collections::{BTreeMap, HashMap, VecDeque}; -use std::sync::{Arc, Mutex, OnceLock, RwLock}; +use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::Image; use effectcraft_segment::MaskModel; use effectcraft_track::roto::refine::{self, MatteParams}; -use effectcraft_track::roto::{self as rb, FrameSeg, RotoData, SegOpts, rle}; +use effectcraft_track::roto::{self as rb, RotoData, SegOpts, rle}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::warp_stab::param; use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider}; -pub use effectcraft_track::roto::{Stroke, StrokeKind}; +pub use effectcraft_track::roto::{FrameSeg, Stroke, StrokeKind}; /// The effect's id. pub const ID: &str = "ec.matte.rotobrush"; @@ -266,6 +267,37 @@ pub fn store(key: u64, scale: f64, seg: Arc) { } } +/// A second-level store for segmentations, keyed like the memory cache (chain key, scale): the +/// persistent disk cache, so computed mattes survive the memory cache and restarts. +pub trait SegStore: Send + Sync { + fn get_seg(&self, key: u64, scale: f64) -> Option; + fn put_seg(&self, key: u64, scale: f64, seg: &FrameSeg); +} + +fn seg_store_slot() -> &'static RwLock>> { + static S: OnceLock>>> = OnceLock::new(); + S.get_or_init(|| RwLock::new(None)) +} + +/// Use `store` behind the memory cache (held weakly: once its owner drops it, e.g. the disk +/// cache is switched off, it is no longer used). `None` turns it off. +pub fn set_seg_store(store: Option>) { + if let Ok(mut s) = seg_store_slot().write() { + *s = store; + } +} + +fn seg_store() -> Option> { + seg_store_slot().read().ok()?.as_ref()?.upgrade() +} + +/// A segmentation from the second-level store, put into the memory cache. +fn from_store(key: u64, scale: f64) -> Option> { + let seg = Arc::new(seg_store()?.get_seg(key, scale)?); + store(key, scale, seg.clone()); + Some(seg) +} + /// Drop the cached segmentations of these chain keys (every scale). pub fn forget(keys: impl IntoIterator) { let keys: std::collections::HashSet = keys.into_iter().collect(); @@ -325,11 +357,11 @@ impl Chain { let sc = self.opts.scale; let lookup = |g: i64| -> Option> { let k = *self.keys.get(&g)?; - if let Some(s) = cached(k, sc) { + if let Some(s) = cached(k, sc).or_else(|| from_store(k, sc)) { return Some(s); } if sc != 1.0 { - let full = cached(k, 1.0)?; + let full = cached(k, 1.0).or_else(|| from_store(k, 1.0))?; let s = Arc::new(downscale(&full, sc)); store(k, sc, s.clone()); return Some(s); @@ -379,7 +411,11 @@ impl Chain { } }; let seg = Arc::new(seg); - store(*self.keys.get(&g)?, sc, seg.clone()); + let k = *self.keys.get(&g)?; + store(k, sc, seg.clone()); + if let Some(st) = seg_store() { + st.put_seg(k, sc, &seg); + } cur = Some(seg); prev_img = Some(img); } diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 34f24786..55c8e415 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -132,6 +132,10 @@ impl Session { }, }; self.layer_cache.set_disk(Some((dc.clone(), footage_salt(&self.project)))); + // Roto Brush / Object Matte segmentations persist there too (held weakly: switching the + // disk cache off drops it). + let store: std::sync::Arc = dc.clone(); + effectcraft_effects::roto::set_seg_store(Some(std::sync::Arc::downgrade(&store))); self.disk_cache = Some(dc); } diff --git a/crates/engine/src/tests_roto.rs b/crates/engine/src/tests_roto.rs index edcab5c1..4b82c8c3 100644 --- a/crates/engine/src/tests_roto.rs +++ b/crates/engine/src/tests_roto.rs @@ -546,3 +546,69 @@ fn object_matte_background_job_cancel_and_freeze() { s.execute("edit.undo", json!({})).unwrap(); assert_eq!(status(&mut s)["frozen"], true); } + +/// Computed Object Matte segmentations go to the persistent disk cache: a second session on the +/// same folder and project gets them back without segmenting (its frames cannot even be read +/// here), and another prompt (other keys) finds nothing. +#[test] +fn object_matte_mattes_persist_in_the_disk_cache() { + use effectcraft_effects::object_matte as om; + use effectcraft_render::disk_cache::Kind; + hold_roto_cache_test_lock(); + let dir = std::env::temp_dir().join(format!("effectcraft-om-disk-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let enable = |s: &mut Session| { + s.execute_checked("prefs.set", json!({"values": {"disk.diskCacheFolder": dir.to_string_lossy(), "disk.diskCacheEnabled": true}})).unwrap(); + }; + let (mut s, clip, solid) = setup(|f| draw(center(f))); + enable(&mut s); + let cid = s.active_comp_id().unwrap(); + s.edit("remove solid", None, |p, _| { + p.comp_mut(cid).unwrap().layers.retain(|l| l.id != solid); + for it in p.items.values_mut() { + if let effectcraft_project::ItemKind::Footage(f) = &mut it.kind { + f.path = "object-matte-disk.mov".into(); + } + } + Ok(()) + }) + .unwrap(); + let c = center(0); + let r = s.execute_checked("objectMatte.apply", json!({"layer": clip.0, "point": c, "before": 0, "after": 5, "frame": 0})).unwrap(); + let uid = r["effect"].as_u64().unwrap(); + s.execute_checked("objectMatte.propagate", json!({"layer": clip.0, "effect": uid, "wait": true})).unwrap(); + let keys = s.roto_keys(cid, clip, uid).unwrap(); + assert_eq!(keys.len(), 6); + let dc = s.disk_cache.clone().unwrap(); + dc.flush(); + assert!(dc.keys(Kind::Matte).len() >= 6, "{:?}", dc.stats()); + let before = alpha_at(&s, 3); + let saved = s.project.clone(); + drop(dc); + drop(s); + effectcraft_effects::roto::forget(keys.iter().map(|(_, k)| *k)); + + // "Reopen": a new session on the same folder and project. + let (mut s2, _, _) = setup(|f| draw(center(f))); + enable(&mut s2); + s2.project = saved; + s2.bump(); + s2.open_comp(cid); + assert_eq!(s2.roto_keys(cid, clip, uid).unwrap(), keys, "the same input key, so the same keys"); + let comp = s2.active_comp().unwrap().clone(); + let l = comp.layer(clip).unwrap(); + let ps = crate::roto::params_static(l.props.find_group(uid).unwrap()); + let size = crate::roto::layer_size(&s2.project, &comp, l); + // No frames to segment from: whatever comes back was read from disk. + for (f, _) in &keys { + assert!(om::ensure(&ps, size, 25.0, *f, None, &mut |_| None, &|| false).is_some(), "frame {f} not reused from disk"); + } + assert!(s2.disk_cache.as_ref().unwrap().stats().hits >= 6); + let again = alpha_at(&s2, 3); + assert_eq!(again, before, "the disk-cached matte renders the same"); + // Another prompt: other keys, nothing on disk. + let mut moved = ps.clone(); + moved.values.insert("objectPoint".into(), effectcraft_keyframe::Value::Vec2([c[0] + 3.0, c[1]])); + assert!(om::ensure(&moved, size, 25.0, 0, None, &mut |_| None, &|| false).is_none()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index da46708e..4140b792 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -8,7 +8,9 @@ //! project therefore never reads stale pixels: its keys simply change. //! * Payloads are LZ4-compressed (`lz4_flex`, MIT): frames as 8-bit premultiplied RGBA, layer //! buffers as `f32` RGBA with the bytes shuffled into planes (exact, so cached and fresh -//! renders are bit-identical). Every file ends in a checksum; damaged files read as misses and +//! renders are bit-identical), Roto Brush / Object Matte segmentations as +//! `FrameSeg::to_bytes` (keyed by their content-keyed chain key and scale, so they are +//! reused whenever the same frames, settings and prompt come back, also after a restart). Every file ends in a checksum; damaged files read as misses and //! are deleted. //! * Size limit with least-recently-used eviction. The in-memory index is rebuilt from the //! folder when the cache opens (file modification times order it), so the cache survives @@ -37,6 +39,8 @@ const QUEUE_LIMIT: usize = 64; pub enum Kind { Frame, Layer, + /// A Roto Brush / Object Matte segmentation. + Matte, } impl Kind { @@ -45,12 +49,14 @@ impl Kind { match self { Kind::Frame => "frames", Kind::Layer => "layers", + Kind::Matte => "mattes", } } fn tag(self) -> u8 { match self { Kind::Frame => 1, Kind::Layer => 2, + Kind::Matte => 3, } } } @@ -319,7 +325,7 @@ impl DiskCache { /// Rebuild the index from the folder (oldest files first). pub fn rescan(&self) { let mut found: Vec<(std::time::SystemTime, Kind, u128, u64)> = vec![]; - for kind in [Kind::Frame, Kind::Layer] { + for kind in [Kind::Frame, Kind::Layer, Kind::Matte] { let Ok(shards) = std::fs::read_dir(self.dir.join(kind.dir())) else { continue }; for shard in shards.flatten() { let Ok(files) = std::fs::read_dir(shard.path()) else { continue }; @@ -403,7 +409,7 @@ impl DiskCache { if let Ok(mut g) = self.index.lock() { g.clear(); } - for kind in [Kind::Frame, Kind::Layer] { + for kind in [Kind::Frame, Kind::Layer, Kind::Matte] { let _ = std::fs::remove_dir_all(self.dir.join(kind.dir())); } } @@ -555,6 +561,22 @@ impl DiskCache { self.write_now(Kind::Layer, key, &encode_layer(buf)); } + /// A segmentation (chain key, scale) written by [`DiskCache::put_seg`]. + pub fn get_seg(&self, key: u64, scale: f64) -> Option { + let p = self.read(Kind::Matte, seg_key(key, scale))?; + let raw = lz4_flex::decompress_size_prepended(&p).ok()?; + effectcraft_effects::roto::FrameSeg::from_bytes(&raw) + } + + /// Queue a segmentation for writing. + pub fn put_seg(&self, key: u64, scale: f64, seg: &effectcraft_effects::roto::FrameSeg) { + let k = seg_key(key, scale); + if self.contains(Kind::Matte, k) { + return; + } + self.enqueue(Kind::Matte, k, lz4_flex::compress_prepend_size(&seg.to_bytes())); + } + /// Write a frame synchronously (tests). pub fn put_frame_now(&self, key: u128, width: u32, height: u32, rgba: &[u8]) { self.write_now(Kind::Frame, key, &encode_frame(width, height, rgba)); @@ -627,6 +649,24 @@ pub fn read_layer_entry(data: &[u8]) -> Option { decode_layer(verify(data, Kind::Layer)?) } +/// The disk key of a segmentation: its chain key and scale. +fn seg_key(key: u64, scale: f64) -> u128 { + let mut h = Hash128::default(); + h.write(b"segmentation-v1"); + h.write_u64(key); + h.write_u64(scale.to_bits()); + h.finish() +} + +impl effectcraft_effects::roto::SegStore for DiskCache { + fn get_seg(&self, key: u64, scale: f64) -> Option { + DiskCache::get_seg(self, key, scale) + } + fn put_seg(&self, key: u64, scale: f64, seg: &effectcraft_effects::roto::FrameSeg) { + DiskCache::put_seg(self, key, scale, seg); + } +} + impl crate::cache::LayerStore for DiskCache { fn get_layer(&self, key: u128) -> Option> { DiskCache::get_layer(self, key).map(Arc::new) @@ -889,6 +929,38 @@ mod tests { assert_eq!(parse_entry_name(&format!("{key:032x}.tmp.1")), None); } + /// Segmentations round-trip by (chain key, scale), survive a reopen (rescan), are bounded by + /// the size limit like every entry, and a damaged file is a miss. + #[test] + fn segmentations_round_trip_and_reopen() { + use effectcraft_effects::roto::FrameSeg; + let dir = tmpdir("seg"); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + let seg = FrameSeg { w: 7, h: 5, matte: (0..35).map(|i| (i % 3 == 0) as u8).collect(), refine: vec![0; 35], ..Default::default() }; + assert!(dc.get_seg(42, 1.0).is_none()); + dc.put_seg(42, 1.0, &seg); + dc.flush(); + assert_eq!(dc.get_seg(42, 1.0), Some(seg.clone())); + assert!(dc.get_seg(42, 0.5).is_none() && dc.get_seg(43, 1.0).is_none()); + assert_eq!(dc.keys(Kind::Matte).len(), 1); + drop(dc); + let dc = DiskCache::open(&dir, 1 << 30).unwrap(); + assert_eq!(dc.get_seg(42, 1.0), Some(seg.clone())); + // Damaged on disk: a miss, and the entry is forgotten. + let path = dc.path(Kind::Matte, seg_key(42, 1.0)); + let mut d = std::fs::read(&path).unwrap(); + d[18] ^= 0x55; + std::fs::write(&path, d).unwrap(); + assert!(dc.get_seg(42, 1.0).is_none()); + assert!(!dc.contains(Kind::Matte, seg_key(42, 1.0))); + // The size limit evicts segmentations too. + dc.put_seg(1, 1.0, &seg); + dc.flush(); + dc.set_max_bytes(1); + assert!(dc.keys(Kind::Matte).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn hit_miss_and_exact_round_trip() { let dir = tmpdir("hit"); diff --git a/docs/agents.md b/docs/agents.md index d94d46bd..8bf3cc87 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -482,7 +482,9 @@ prompt. `objectMatte.propagate {layer, effect?, direction?, wait?}` tracks the m frames around the reference frame in the background (`roto.cancel` stops it; edits to the prompt or the layer's frames queue it again), `objectMatte.freeze {wait?}` / `objectMatte.unfreeze` store or drop the final mattes, and `objectMatte.status {layer, effect?}` reports the job's -progress, the frozen state, the tracked frames and which are computed. +progress, the frozen state, the tracked frames and which are computed. With the disk cache on (Settings ▸ +Media & Disk Cache), computed Object Matte and Roto Brush segmentations are also kept there, keyed +by what they are made of, so reopening a project reuses them. ```sh effectcraft-cli run clip.ecproj objectMatte.apply '{"layer":"#1","point":[320,200],"box":[250,120,400,300]}' \