From c08e6f6d5fac59b497eb9324e9db1f57878bfeef Mon Sep 17 00:00:00 2001 From: JonRC Date: Mon, 2 Nov 2026 11:04:25 -0300 Subject: [PATCH] Roto Brush: keep frozen mattes in a session without their model (#2833) The frozen key hashes the chain keys, whose seed holds the session's model, so mattes frozen with SAM 2.1 didn't match in a session on Classic, such as a fresh effectcraft-cli render, and the frame was segmented again with Classic. Accept the key under any Roto Brush model too, as Object Matte has done since #1852. frozen_key is unchanged for projects frozen today, and editing a stroke or a matte setting still makes the frozen mattes stale. --- crates/effects/src/roto.rs | 75 +++++++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index b53fa5d65..5a96a1555 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -9,7 +9,8 @@ //! neighbouring frames with [`crate::EffectHost::self_at`]). Undo, redo and reopening a project //! 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. +//! parameter ([`FrozenCache`]); they are used while their key still matches, whichever model the +//! session has (a session without the model that froze them uses them as they are). //! //! Rendering: binary segmentation → Roto Brush Matte (Shift Edge, Reduce Chatter, Feather, //! Contrast) → Refine Edge matting inside the Refine Edge band (guided filter + closed-form @@ -242,6 +243,11 @@ fn seed_with(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u6 h } +/// Show Object: 0 shows every object, N object N alone. +fn show_object_of(params: &Params) -> u32 { + f(params, "rotoBrushMatte/object").round().clamp(0.0, 32.0) as u32 +} + /// The layer frame shown at layer time `t` seconds. pub fn frame_of(t: f64, fps: f64) -> i64 { (t * fps + 1e-6).floor() as i64 @@ -415,7 +421,7 @@ impl Chain { let data = data(params); let keys = data.chain_keys(seed(params, layer_size, fps)); let saved = saved_for(params, &data, layer_size, fps); - let show_object = f(params, "rotoBrushMatte/object").round().clamp(0.0, 32.0) as u32; + let show_object = show_object_of(params); Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params), saved, show_object } } @@ -869,7 +875,8 @@ fn needs_neighbours(mp: &MatteParams) -> bool { // ------------------------------------------------------------------ Freeze /// Frozen mattes (Freeze): the final 8-bit alpha and the Refine Edge band of every frame of -/// the span at full resolution, valid while `key` matches [`frozen_key`]. +/// the span at full resolution, valid while `key` matches [`frozen_key`] under the session's model or +/// any other Roto Brush model. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct FrozenCache { @@ -899,21 +906,35 @@ impl FrozenCache { } } -/// What frozen mattes depend on: every chain key and the matte settings. +/// What frozen mattes depend on: every chain key (the session's model is in their seed) and the matte settings. pub fn frozen_key(params: &Params, layer_size: [f64; 2], fps: f64) -> String { - let ch = Chain::new(params, layer_size, fps, 1.0); + frozen_key_with(params, layer_size, fps, model_for(params).map_or(effectcraft_segment::CLASSICAL, |m| m.info().id)) +} + +/// [`frozen_key`] for the model with id `model` ([`effectcraft_segment::CLASSICAL`]: none). +fn frozen_key_with(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> String { let mut h = 0xcbf2_9ce4_8422_2325; - for (f, k) in &ch.keys { + for (f, k) in &data(params).chain_keys(seed_with(params, layer_size, fps, model)) { fnv(&mut h, &f.to_le_bytes()); fnv(&mut h, &k.to_le_bytes()); } fnv(&mut h, format!("{:?}", matte_params(params)).as_bytes()); - if ch.show_object != 0 { - fnv(&mut h, &ch.show_object.to_le_bytes()); + let show_object = show_object_of(params); + if show_object != 0 { + fnv(&mut h, &show_object.to_le_bytes()); } format!("{h:016x}") } +/// Whether frozen mattes still apply: their key matches under the session's model, or under the model that froze +/// them, which this session may not have loaded (a fresh headless render). Frozen mattes need no segmentation. +fn frozen_applies(fc: &FrozenCache, params: &Params, layer_size: [f64; 2], fps: f64) -> bool { + fc.key == frozen_key(params, layer_size, fps) + || std::iter::once(effectcraft_segment::CLASSICAL) + .chain(effectcraft_segment::MODELS.iter().filter(|m| m.task == effectcraft_segment::Task::Mask).map(|m| m.id)) + .any(|id| fc.key == frozen_key_with(params, layer_size, fps, id)) +} + /// Whether the instance is frozen (has frozen mattes). pub fn is_frozen(params: &Params) -> bool { !s(params, FROZEN).is_empty() @@ -1132,7 +1153,7 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc, scale: f64) -> Option<(V let (w, h) = (img.width as usize, img.height as usize); // Frozen mattes. if let Some(fc) = frozen(ctx.params) - && fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps()) + && frozen_applies(&fc, ctx.params, ctx.layer_size, ctx.fps()) && let Some(fr) = fc.frames.get(&f) { let [fw, fh] = fc.size; @@ -1351,6 +1372,42 @@ mod tests { assert!(o.data.iter().all(|p| p[3] == 0.0)); } + /// Mattes frozen with a model render as frozen in a session that has another model or none (a fresh headless + /// render), instead of being segmented again with the classic engine. + #[test] + fn frozen_mattes_survive_a_session_without_their_model() { + let mut d = RotoData::default(); + d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0], [45.0, 40.0]], object: 0 }, 2, [0, 5]); + let mut params = params_with(&d); + params.values.insert(INPUT_KEY.into(), Value::Str("frozen-without-the-model".into())); + let size = [100.0, 80.0]; + assert!(model_for(¶ms).is_none(), "this session has no model"); + // the frozen matte is the left half of the layer: no segmentation of the disk gives it + let alpha: Vec = (0..100 * 80).map(|i| f32::from(u8::from(i % 100 < 50))).collect(); + let mut fc = FrozenCache { key: frozen_key_with(¶ms, size, 25.0, effectcraft_segment::SAM2_TINY.id), size: [100, 80], ..Default::default() }; + fc.frames.insert(0, freeze_frame(&alpha, &[0; 100 * 80])); + assert_ne!(fc.key, frozen_key(¶ms, size, 25.0)); + params.values.insert(FROZEN.into(), Value::Str(fc.to_json())); + let host = Seq; + let ctx = EffectCtx { + params: ¶ms, + time: 0.001, + layer_size: size, + seed: 1, + adjustment: false, + env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() }, + }; + let out = crate::apply(&specs()[0], &ctx, Buf { img: frame(0.0), offset: [0.0; 2], scale: 1.0 }).img; + let a = |x: usize, y: usize| out.data[y * 100 + x][3]; + assert!(a(10, 70) > 0.99 && a(90, 10) < 0.01, "the frozen matte is used: {} at (10, 70), {} at (90, 10)", a(10, 70), a(90, 10)); + // a stroke edited after the freeze still makes it stale, whatever the model + let mut moved = d.clone(); + moved.strokes[0].points[0] = [36.0, 40.0]; + let mut edited = params.clone(); + edited.values.insert(STROKES.into(), Value::Str(moved.to_json())); + assert!(!frozen_applies(&fc, &edited, size, 25.0)); + } + /// A stand-in tracker for two objects: object 0 is the moving disk, object 1 a fixed square. It records how many /// objects (and prompts per object) it was asked for. struct TwoObjects(std::sync::Mutex>>);