From 4d8846429fa2dca8bdb40d8d0bac4261a9ec8592 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 15:16:25 +0000 Subject: [PATCH] fix(track): keep Roto Brush spans around their base frame within a frame limit; index strokes by frame --- crates/track/src/roto.rs | 23 +++++++++++++++++++++-- crates/track/src/roto/tests.rs | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/track/src/roto.rs b/crates/track/src/roto.rs index 3bab65dff..92639d550 100644 --- a/crates/track/src/roto.rs +++ b/crates/track/src/roto.rs @@ -116,12 +116,25 @@ fn fnv(h: &mut u64, bytes: &[u8]) { } } +/// Frames a segmentation span may reach on each side of its base frame (72 minutes at 60 fps): +/// every frame of the span gets a content key, and the propagation jobs visit each one. +pub const MAX_SPAN_FRAMES: i64 = 1 << 18; + impl RotoData { + /// Read saved data. The span is kept around the base frame and within [`MAX_SPAN_FRAMES`] + /// of it on each side, whatever the file says. pub fn from_json(s: &str) -> RotoData { if s.trim().is_empty() { return RotoData::default(); } - serde_json::from_str(s).unwrap_or_default() + let mut d: RotoData = serde_json::from_str(s).unwrap_or_default(); + d.bound_span(); + d + } + fn bound_span(&mut self) { + let Some(base) = self.base else { return }; + let [a, b] = self.span; + self.span = [a.min(base).max(base.saturating_sub(MAX_SPAN_FRAMES)), b.max(base).min(base.saturating_add(MAX_SPAN_FRAMES))]; } pub fn to_json(&self) -> String { serde_json::to_string(self).unwrap_or_default() @@ -169,6 +182,7 @@ impl RotoData { } None => {} } + self.bound_span(); self.strokes.push(s); } /// Largest Refine Edge stroke radius (layer pixels). @@ -181,10 +195,15 @@ impl RotoData { pub fn chain_keys(&self, seed: u64) -> BTreeMap { let mut out = BTreeMap::new(); let Some(base) = self.base else { return out }; + // Strokes by frame, once: not a pass over every stroke for each frame of the span. + let mut by_frame: BTreeMap> = BTreeMap::new(); + for s in &self.strokes { + by_frame.entry(s.frame).or_default().push(s); + } let key = |prev: u64, f: i64| { let mut h = prev ^ 0x9e37_79b9_7f4a_7c15; fnv(&mut h, &f.to_le_bytes()); - for s in self.strokes.iter().filter(|s| s.frame == f) { + for s in by_frame.get(&f).into_iter().flatten() { fnv(&mut h, serde_json::to_string(s).unwrap_or_default().as_bytes()); } h diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs index e50dbbce8..40d154e32 100644 --- a/crates/track/src/roto/tests.rs +++ b/crates/track/src/roto/tests.rs @@ -829,4 +829,22 @@ fn propagate_survives_empty_frames() { assert_eq!((next.w, next.h), (w as usize, h as usize)); assert!(next.matte.is_empty()); } + +#[test] +fn saved_spans_stay_around_the_base_frame_within_the_limit() { + let mut d = RotoData::default(); + for s in base_strokes(5) { + d.add(s, 20, [0, 30]); + } + d.span = [i64::MIN, i64::MAX]; + let r = RotoData::from_json(&d.to_json()); + assert_eq!(r.span, [5 - MAX_SPAN_FRAMES, 5 + MAX_SPAN_FRAMES]); + assert_eq!(r.chain_keys(1).len() as i64, 2 * MAX_SPAN_FRAMES + 1); + // A span that leaves out its base frame is widened to hold it. + d.span = [10, 20]; + assert_eq!(RotoData::from_json(&d.to_json()).span, [5, 20]); + // A stroke far away extends the span only up to the limit. + let mut e = RotoData::from_json(&RotoData { span: [0, 25], ..d.clone() }.to_json()); + e.add(line(StrokeKind::Fg, i64::MAX, [0.0, 0.0], [1.0, 1.0], 3.0), 20, [0, 30]); + assert_eq!(e.span, [0, 5 + MAX_SPAN_FRAMES]); }