mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 18:47:07 -06:00
#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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
560767dfe7
commit
3466538d0e
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user