#783: Object Matte mattes persist in the disk cache

Problem: computed Object Matte segmentations lived only in the in-memory
cache, so every restart or memory eviction segmented the shot again (After
Effects 26.5 keeps Object Matte results in its disk cache).

Fix: the persistent disk cache gets a third entry kind, Matte (folder
`mattes`, LZ4-compressed FrameSeg bytes, same checksum, atomic writes, LRU
eviction under the one size limit, purged with the rest). Entries are keyed by
the segmentation's chain key and scale; chain keys already hash the Input Key
(the layer's source, timing, masks and effects above), the prompt, the mode and
the model, so any change simply misses. The effects crate gets a SegStore
trait behind its memory cache: Chain::ensure reads from it on a memory miss
and writes what it computes. The engine installs the session's DiskCache as
the store (held weakly, so switching the disk cache off drops it). Roto Brush
segmentations go through the same chain and are persisted too. Frozen mattes
already persist in the project (the Frozen parameter).

Tests: engine tests_roto::object_matte_mattes_persist_in_the_disk_cache (a
second session on the same folder and project gets all tracked frames back
without being able to read a single input frame, renders identically, and a
moved prompt finds nothing); render disk_cache
segmentations_round_trip_and_reopen (round trip, reopen, damaged file is a
miss, size limit evicts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-10-11 14:06:33 +02:00
1 parent 560767dfe7
commit 3466538d0e
5 files changed
+191 -11

No files matched your search

+43 -7
View File
@@ -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<FrameSeg>) {
}
}
/// 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<FrameSeg>;
fn put_seg(&self, key: u64, scale: f64, seg: &FrameSeg);
}
fn seg_store_slot() -> &'static RwLock<Option<Weak<dyn SegStore>>> {
static S: OnceLock<RwLock<Option<Weak<dyn SegStore>>>> = 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<Weak<dyn SegStore>>) {
if let Ok(mut s) = seg_store_slot().write() {
*s = store;
}
}
fn seg_store() -> Option<Arc<dyn SegStore>> {
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<Arc<FrameSeg>> {
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<Item = u64>) {
let keys: std::collections::HashSet<u64> = keys.into_iter().collect();
@@ -325,11 +357,11 @@ impl Chain {
let sc = self.opts.scale;
let lookup = |g: i64| -> Option<Arc<FrameSeg>> {
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);
}
+4
View File
@@ -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<dyn effectcraft_effects::roto::SegStore> = dc.clone();
effectcraft_effects::roto::set_seg_store(Some(std::sync::Arc::downgrade(&store)));
self.disk_cache = Some(dc);
}
+66
View File
@@ -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);
}
+75 -3
View File
@@ -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<effectcraft_effects::roto::FrameSeg> {
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<Buf> {
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<effectcraft_effects::roto::FrameSeg> {
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<Arc<Buf>> {
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");
+3 -1
View File
@@ -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]}' \