mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 06:27:11 -06:00
1748 lines
80 KiB
Rust
1748 lines
80 KiB
Rust
//! Roto Brush & Refine Edge (applied by the Roto Brush tool; Effect ▸ Matte).
|
||
//!
|
||
//! The instance stores the user's strokes, base frame and segmentation span as JSON in its hidden
|
||
//! **Strokes** parameter ([`RotoData`]); the segmentation itself is *derived* data: each frame's
|
||
//! result is a deterministic function of its chain key ([`RotoData::chain_keys`] seeded with
|
||
//! [`seed`]: what the frames are, the Roto Brush settings and the strokes from the base frame
|
||
//! 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. 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,
|
||
//! Contrast) → Refine Edge matting inside the Refine Edge band (guided filter + closed-form
|
||
//! matting with Higher Quality) → Refine Edge Matte adjustments → matte motion blur →
|
||
//! decontamination of the edge colours. Outside the segmentation span the layer is transparent;
|
||
//! without strokes the effect passes its input through.
|
||
//!
|
||
//! *Version*: 1.0 is the classic graph-cut engine; 2.0 and 3.0 also use the trained model chosen
|
||
//! in Settings ▸ Roto Brush ([`set_model`], an `effectcraft_segment::MaskModel` such as
|
||
//! MobileSAM), falling back to the classic engine when none is installed. The model's id is part
|
||
//! of the chain seed, so choosing another recomputes the mattes. *Quality ▸ Best* segments at a
|
||
//! higher working resolution with more iterations.
|
||
|
||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||
use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak};
|
||
|
||
use effectcraft_keyframe::Value;
|
||
use effectcraft_project::ParamUi;
|
||
use effectcraft_raster::Image;
|
||
use effectcraft_segment::{ClipFrame, MaskModel, TrackRequest};
|
||
use effectcraft_track::roto::refine::{self, MatteParams};
|
||
use effectcraft_track::roto::{self as rb, Gmm, 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::{FrameSeg, Stroke, StrokeKind};
|
||
|
||
mod pending;
|
||
|
||
/// The effect's id.
|
||
pub const ID: &str = "ec.matte.rotobrush";
|
||
/// Hidden parameters.
|
||
pub const STROKES: &str = "strokes";
|
||
pub const INPUT_KEY: &str = "inputKey";
|
||
pub const FROZEN: &str = "frozen";
|
||
/// The saved tracking (see [`TrackCache`]).
|
||
pub const TRACKED: &str = "tracked";
|
||
|
||
/// Default segmentation span each side of the base frame (frames).
|
||
pub const DEFAULT_SPAN: i64 = 20;
|
||
|
||
/// Display names of the instance's parameter groups.
|
||
pub const GROUPS: &[(&str, &str)] = &[
|
||
("rotoBrushPropagation", "Roto Brush Propagation"),
|
||
("rotoBrushMatte", "Roto Brush Matte"),
|
||
("refineEdgeMatte", "Refine Edge Matte"),
|
||
("motionBlur", "Motion Blur"),
|
||
("decontamination", "Decontamination"),
|
||
];
|
||
|
||
pub const VERSIONS: [&str; 3] = ["1.0", "2.0", "3.0"];
|
||
pub const QUALITIES: [&str; 2] = ["Standard", "Best"];
|
||
|
||
pub fn specs() -> Vec<EffectSpec> {
|
||
vec![EffectSpec {
|
||
id: ID,
|
||
name: "Roto Brush & Refine Edge",
|
||
category: "Matte",
|
||
params: vec![
|
||
p("version", "Version", Value::Enum(2), popup(&VERSIONS)),
|
||
p("quality", "Quality", Value::Enum(0), popup(&QUALITIES)),
|
||
p("rotoBrushPropagation/searchRadius", "Search Radius", num(15.0), slider(0.0, 100.0, 0.0, 50.0, 1)),
|
||
p("rotoBrushPropagation/motionThreshold", "Motion Threshold", num(10.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||
p("rotoBrushPropagation/motionDamping", "Motion Damping", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||
p("rotoBrushPropagation/viewSearchRegion", "View Search Region", Value::Bool(false), ParamUi::Checkbox),
|
||
p("rotoBrushMatte/feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||
p("rotoBrushMatte/contrast", "Contrast", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||
p("rotoBrushMatte/shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)),
|
||
p("rotoBrushMatte/reduceChatter", "Reduce Chatter", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||
p("rotoBrushMatte/object", "Show Object", num(0.0), slider(0.0, 32.0, 0.0, 32.0, 0)),
|
||
p("refineEdgeMatte/smooth", "Smooth", num(1.0), slider(0.0, 100.0, 0.0, 10.0, 1)),
|
||
p("refineEdgeMatte/feather", "Feather", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||
p("refineEdgeMatte/contrast", "Contrast", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||
p("refineEdgeMatte/shiftEdge", "Shift Edge", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 0)),
|
||
p("refineEdgeMatte/reduceChatter", "Reduce Chatter", num(0.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||
p("refineEdgeMatte/useMotionBlur", "Use Motion Blur", Value::Bool(false), ParamUi::Checkbox),
|
||
p("refineEdgeMatte/motionBlur/motionBlurSamples", "Samples", num(11.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
|
||
p("refineEdgeMatte/motionBlur/shutterAngle", "Shutter Angle", num(180.0), slider(0.0, 720.0, 0.0, 360.0, 0)),
|
||
p("refineEdgeMatte/motionBlur/higherQuality", "Higher Quality", Value::Bool(true), ParamUi::Checkbox),
|
||
p("refineEdgeMatte/decontaminateEdgeColors", "Decontaminate Edge Colors", Value::Bool(true), ParamUi::Checkbox),
|
||
p("refineEdgeMatte/decontamination/decontaminationAmount", "Decontamination Amount", num(100.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||
p("refineEdgeMatte/decontamination/extendWhereSmoothed", "Extend Where Smoothed", Value::Bool(true), ParamUi::Checkbox),
|
||
p("refineEdgeMatte/decontamination/increaseDecontaminationRadius", "Increase Decontamination Radius", num(0.0), slider(0.0, 100.0, 0.0, 20.0, 1)),
|
||
p("refineEdgeMatte/decontamination/viewDecontaminationMap", "View Decontamination Map", Value::Bool(false), ParamUi::Checkbox),
|
||
p(STROKES, "Strokes", Value::Str(String::new()), ParamUi::Hidden),
|
||
p(INPUT_KEY, "Input Key", Value::Str(String::new()), ParamUi::Hidden),
|
||
p(FROZEN, "Frozen", Value::Str(String::new()), ParamUi::Hidden),
|
||
p(TRACKED, "Tracked", Value::Str(String::new()), ParamUi::Hidden),
|
||
],
|
||
render,
|
||
gpu: false,
|
||
float: true,
|
||
}]
|
||
}
|
||
|
||
fn f(params: &Params, id: &str) -> f64 {
|
||
param(params, id).map(Value::as_f64).unwrap_or(0.0)
|
||
}
|
||
fn e(params: &Params, id: &str) -> u32 {
|
||
param(params, id).map(Value::as_enum).unwrap_or(0)
|
||
}
|
||
fn b(params: &Params, id: &str) -> bool {
|
||
param(params, id).map(Value::as_bool).unwrap_or(false)
|
||
}
|
||
fn s<'a>(params: &'a Params, id: &str) -> &'a str {
|
||
match param(params, id) {
|
||
Some(Value::Str(s)) => s,
|
||
_ => "",
|
||
}
|
||
}
|
||
|
||
fn fnv(h: &mut u64, bytes: &[u8]) {
|
||
for b in bytes {
|
||
*h ^= *b as u64;
|
||
*h = h.wrapping_mul(0x0100_0000_01b3);
|
||
}
|
||
}
|
||
|
||
/// The instance's strokes (parsed once per content).
|
||
pub fn data(params: &Params) -> Arc<RotoData> {
|
||
static C: OnceLock<Mutex<HashMap<u64, Arc<RotoData>>>> = OnceLock::new();
|
||
let j = s(params, STROKES);
|
||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||
fnv(&mut h, j.as_bytes());
|
||
let c = C.get_or_init(Default::default);
|
||
if let Some(d) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&h) {
|
||
return d.clone();
|
||
}
|
||
let d = Arc::new(RotoData::from_json(j));
|
||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||
if m.len() > 64 {
|
||
m.clear();
|
||
}
|
||
m.insert(h, d.clone());
|
||
d
|
||
}
|
||
|
||
/// Segmentation settings at `scale` (pixels per layer pixel).
|
||
pub fn seg_opts(params: &Params, scale: f64) -> SegOpts {
|
||
SegOpts {
|
||
scale,
|
||
search_radius: f(params, "rotoBrushPropagation/searchRadius").max(0.0),
|
||
motion_threshold: f(params, "rotoBrushPropagation/motionThreshold"),
|
||
motion_damping: f(params, "rotoBrushPropagation/motionDamping"),
|
||
best: e(params, "quality") == 1,
|
||
}
|
||
}
|
||
|
||
/// Matte settings.
|
||
pub fn matte_params(params: &Params) -> MatteParams {
|
||
MatteParams {
|
||
rb_feather: f(params, "rotoBrushMatte/feather").max(0.0),
|
||
rb_contrast: f(params, "rotoBrushMatte/contrast"),
|
||
rb_shift: f(params, "rotoBrushMatte/shiftEdge"),
|
||
rb_chatter: f(params, "rotoBrushMatte/reduceChatter"),
|
||
smooth: f(params, "refineEdgeMatte/smooth").max(0.0),
|
||
feather: f(params, "refineEdgeMatte/feather").max(0.0),
|
||
contrast: f(params, "refineEdgeMatte/contrast"),
|
||
shift: f(params, "refineEdgeMatte/shiftEdge"),
|
||
chatter: f(params, "refineEdgeMatte/reduceChatter"),
|
||
motion_blur: b(params, "refineEdgeMatte/useMotionBlur"),
|
||
mb_samples: f(params, "refineEdgeMatte/motionBlur/motionBlurSamples").round().clamp(1.0, 64.0) as usize,
|
||
shutter_angle: f(params, "refineEdgeMatte/motionBlur/shutterAngle"),
|
||
higher_quality: b(params, "refineEdgeMatte/motionBlur/higherQuality"),
|
||
decontaminate: b(params, "refineEdgeMatte/decontaminateEdgeColors"),
|
||
decontamination: f(params, "refineEdgeMatte/decontamination/decontaminationAmount"),
|
||
extend_where_smoothed: b(params, "refineEdgeMatte/decontamination/extendWhereSmoothed"),
|
||
increase_radius: f(params, "refineEdgeMatte/decontamination/increaseDecontaminationRadius").max(0.0),
|
||
}
|
||
}
|
||
|
||
fn model_slot() -> &'static RwLock<Option<Arc<dyn MaskModel>>> {
|
||
static M: OnceLock<RwLock<Option<Arc<dyn MaskModel>>>> = OnceLock::new();
|
||
M.get_or_init(|| RwLock::new(None))
|
||
}
|
||
|
||
/// The trained model Roto Brush 2.0 / 3.0 use (Settings ▸ Roto Brush; `None`: the classic
|
||
/// engine for every version).
|
||
pub fn set_model(m: Option<Arc<dyn MaskModel>>) {
|
||
if let Ok(mut slot) = model_slot().write() {
|
||
*slot = m;
|
||
}
|
||
}
|
||
|
||
/// Whether an instance with `params` segments with the model Settings chose: Roto Brush 2.0 and
|
||
/// 3.0 do, 1.0 is the classic engine.
|
||
pub fn uses_model(params: &Params) -> bool {
|
||
e(params, "version") != 0
|
||
}
|
||
|
||
/// The model an instance segments with: its version's choice of the installed one.
|
||
pub fn model_for(params: &Params) -> Option<Arc<dyn MaskModel>> {
|
||
if !uses_model(params) {
|
||
return None;
|
||
}
|
||
installed_model()
|
||
}
|
||
|
||
/// The trained model chosen in Settings ▸ Roto Brush, if one is installed (Roto Brush 2.0 / 3.0
|
||
/// and Object Matte segment with it).
|
||
pub fn installed_model() -> Option<Arc<dyn MaskModel>> {
|
||
model_slot().read().ok().and_then(|m| m.clone())
|
||
}
|
||
|
||
/// Seed of the chain keys: what the frames are (the engine-maintained Input Key), the layer
|
||
/// size, the frame rate, the segmentation settings and the model.
|
||
pub fn seed(params: &Params, layer_size: [f64; 2], fps: f64) -> u64 {
|
||
seed_with(params, layer_size, fps, model_for(params).map_or(effectcraft_segment::CLASSICAL, |m| m.info().id))
|
||
}
|
||
|
||
/// [`seed`] with the segmenting model named by its id (saved tracking names the one that made it).
|
||
fn seed_with(params: &Params, layer_size: [f64; 2], fps: f64, model: &str) -> u64 {
|
||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||
fnv(&mut h, s(params, INPUT_KEY).as_bytes());
|
||
for v in [
|
||
layer_size[0],
|
||
layer_size[1],
|
||
fps,
|
||
f(params, "rotoBrushPropagation/searchRadius"),
|
||
f(params, "rotoBrushPropagation/motionThreshold"),
|
||
f(params, "rotoBrushPropagation/motionDamping"),
|
||
] {
|
||
fnv(&mut h, &v.to_bits().to_le_bytes());
|
||
}
|
||
fnv(&mut h, &[e(params, "quality") as u8, e(params, "version") as u8]);
|
||
fnv(&mut h, model.as_bytes());
|
||
h
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
// ------------------------------------------------------------------ derived-data cache
|
||
|
||
struct SegCache {
|
||
map: HashMap<(u64, u64), Arc<FrameSeg>>,
|
||
order: VecDeque<(u64, u64)>,
|
||
bytes: usize,
|
||
}
|
||
|
||
const CACHE_BYTES: usize = 768 << 20;
|
||
|
||
fn cache() -> &'static Mutex<SegCache> {
|
||
static C: OnceLock<Mutex<SegCache>> = OnceLock::new();
|
||
C.get_or_init(|| Mutex::new(SegCache { map: HashMap::new(), order: VecDeque::new(), bytes: 0 }))
|
||
}
|
||
|
||
/// A cached segmentation (chain key, scale).
|
||
pub fn cached(key: u64, scale: f64) -> Option<Arc<FrameSeg>> {
|
||
cache().lock().unwrap_or_else(|e| e.into_inner()).map.get(&(key, scale.to_bits())).cloned()
|
||
}
|
||
|
||
pub fn store(key: u64, scale: f64, seg: Arc<FrameSeg>) {
|
||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||
let k = (key, scale.to_bits());
|
||
if c.map.contains_key(&k) {
|
||
return;
|
||
}
|
||
c.bytes += seg.matte.len() + seg.refine.len();
|
||
c.map.insert(k, seg);
|
||
c.order.push_back(k);
|
||
while c.bytes > CACHE_BYTES {
|
||
let Some(old) = c.order.pop_front() else { break };
|
||
if let Some(s) = c.map.remove(&old) {
|
||
c.bytes -= s.matte.len() + s.refine.len();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Tracker results live under the chain key mixed with this, so a frame the tracker followed and
|
||
/// one the classic engine computed never share a cache slot.
|
||
const TRACK_SALT: u64 = 0x7472_6163_6b65_7231;
|
||
|
||
fn tracked_key(key: u64) -> u64 {
|
||
key.rotate_left(17) ^ TRACK_SALT
|
||
}
|
||
|
||
/// Where one object's matte of a tracked frame lives (kept only when the instance has several objects).
|
||
fn object_key(key: u64, object: u32) -> u64 {
|
||
tracked_key(key).wrapping_add(0x9e37_79b9_7f4a_7c15u64.wrapping_mul(u64::from(object) + 1))
|
||
}
|
||
|
||
/// How far to trust a frame the tracker followed.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct FrameTrust {
|
||
/// 0–1, from the tracker's own object score and mask quality.
|
||
pub confidence: f32,
|
||
/// The object is not in this frame (an empty matte).
|
||
pub occluded: bool,
|
||
}
|
||
|
||
fn trust_cache() -> &'static Mutex<HashMap<u64, FrameTrust>> {
|
||
static T: OnceLock<Mutex<HashMap<u64, FrameTrust>>> = OnceLock::new();
|
||
T.get_or_init(|| Mutex::new(HashMap::new()))
|
||
}
|
||
|
||
/// The tracker's confidence in the frame with chain key `key`, if the tracker followed it.
|
||
pub fn trust(key: u64) -> Option<FrameTrust> {
|
||
trust_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&tracked_key(key)).copied()
|
||
}
|
||
|
||
/// Drop the cached segmentations of these chain keys (every scale).
|
||
pub fn forget(keys: impl IntoIterator<Item = u64>) {
|
||
let mut trusts = trust_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||
let keys: std::collections::HashSet<u64> = keys.into_iter().flat_map(|k| [k, tracked_key(k)]).collect();
|
||
trusts.retain(|k, _| !keys.contains(k));
|
||
drop(trusts);
|
||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||
let gone: Vec<(u64, u64)> = c.map.keys().filter(|k| keys.contains(&k.0)).copied().collect();
|
||
for k in gone {
|
||
if let Some(s) = c.map.remove(&k) {
|
||
c.bytes -= s.matte.len() + s.refine.len();
|
||
}
|
||
}
|
||
c.order.retain(|k| !keys.contains(&k.0));
|
||
}
|
||
|
||
/// Drop every cached segmentation (Edit ▸ Purge).
|
||
pub fn purge() {
|
||
trust_cache().lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||
c.map.clear();
|
||
c.order.clear();
|
||
c.bytes = 0;
|
||
}
|
||
|
||
/// The layer-space grid (`round(size · scale)` pixels) of a buffer.
|
||
pub fn layer_grid(buf: &Buf, layer_size: [f64; 2]) -> Image {
|
||
let s = if buf.scale > 0.0 { buf.scale } else { 1.0 };
|
||
let (w, h) = (((layer_size[0] * s).round() as u32).max(1), ((layer_size[1] * s).round() as u32).max(1));
|
||
let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64);
|
||
let mut img = Image::new(w, h);
|
||
img.rows_mut().for_each(|(y, row)| {
|
||
for (x, px) in row.iter_mut().enumerate() {
|
||
*px = buf.img.get(x as i64 + ox, y as i64 + oy);
|
||
}
|
||
});
|
||
img
|
||
}
|
||
|
||
/// What a tracking pass found.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct TrackSummary {
|
||
pub frames: usize,
|
||
/// Frames where the object was absent.
|
||
pub occluded: usize,
|
||
pub min_confidence: f32,
|
||
}
|
||
|
||
/// Everything needed to compute an instance's segmentations.
|
||
pub struct Chain {
|
||
pub data: Arc<RotoData>,
|
||
pub keys: BTreeMap<i64, u64>,
|
||
pub opts: SegOpts,
|
||
pub refine_radius: f64,
|
||
/// The trained model (Version 2.0 / 3.0 with one installed).
|
||
pub model: Option<Arc<dyn MaskModel>>,
|
||
/// The saved tracking, while it applies (see [`TrackCache`]).
|
||
pub(crate) saved: Option<SavedTracking>,
|
||
/// Show Object: the matte of this object only (its id plus one; 0 shows all of them together).
|
||
pub show_object: u32,
|
||
}
|
||
|
||
impl Chain {
|
||
pub fn new(params: &Params, layer_size: [f64; 2], fps: f64, scale: f64) -> 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;
|
||
Chain { refine_radius: data.refine_radius(), data, keys, opts: seg_opts(params, scale), model: model_for(params), saved, show_object }
|
||
}
|
||
|
||
/// Frame `f`'s segmentation at `scale` from the saved tracking or the caches; computes nothing.
|
||
pub fn cached_seg(&self, f: i64, scale: f64) -> Option<Arc<FrameSeg>> {
|
||
self.saved_seg(f, scale).or_else(|| self.keys.get(&f).and_then(|k| cached(*k, scale)))
|
||
}
|
||
|
||
/// Object `o`'s matte of frame `f` at this chain's scale (saved tracking, else this session's), when the instance
|
||
/// has several objects and the tracker followed them there; computes nothing.
|
||
pub fn object_seg(&self, f: i64, o: u32) -> Option<Arc<FrameSeg>> {
|
||
let sc = self.opts.scale;
|
||
// A frame the saved tracking has is read from it, under the keys it was made with: the model chosen now
|
||
// may not be the one that tracked it, and its own cache must not hold another model's matte.
|
||
let saved = self.saved.as_ref().filter(|t| t.cache.frames.contains_key(&f)).and_then(|t| t.keys.get(&f).map(|k| (t, *k)));
|
||
let k = match saved {
|
||
Some((_, saved_key)) => saved_key,
|
||
None => *self.keys.get(&f)?,
|
||
};
|
||
let ok = object_key(k, o);
|
||
if let Some(s) = cached(ok, sc) {
|
||
return Some(s);
|
||
}
|
||
let full = match cached(ok, 1.0) {
|
||
Some(s) => s,
|
||
None => {
|
||
let (t, _) = saved?;
|
||
let s = Arc::new(decode_object(&t.cache, f, o)?);
|
||
store(ok, 1.0, s.clone());
|
||
s
|
||
}
|
||
};
|
||
if sc == 1.0 {
|
||
return Some(full);
|
||
}
|
||
let s = Arc::new(downscale(&full, sc));
|
||
store(ok, sc, s.clone());
|
||
Some(s)
|
||
}
|
||
|
||
/// What Show Object asks for at frame `f`: `None` shows the whole matte (all objects, a single object, or a frame
|
||
/// the tracker did not follow); a missing object on a tracked frame shows nothing.
|
||
fn shown_object(&self, f: i64) -> Option<Arc<FrameSeg>> {
|
||
let o = self.show_object.checked_sub(1)?;
|
||
let objects = self.data.objects();
|
||
if objects.len() < 2 {
|
||
return None;
|
||
}
|
||
if objects.contains(&o)
|
||
&& let Some(seg) = self.object_seg(f, o)
|
||
{
|
||
return Some(seg);
|
||
}
|
||
let sc = self.opts.scale;
|
||
let tracked = self.saved_seg(f, sc).or_else(|| {
|
||
let key = tracked_key(*self.keys.get(&f)?);
|
||
cached(key, sc).or_else(|| cached(key, 1.0).map(|seg| Arc::new(downscale(&seg, sc))))
|
||
})?;
|
||
Some(Arc::new(FrameSeg::empty(tracked.w, tracked.h)))
|
||
}
|
||
|
||
/// The tracker's trust in frame `f`: from the saved tracking, else from this session's tracking.
|
||
pub fn trust_at(&self, f: i64) -> Option<FrameTrust> {
|
||
if let Some(fr) = self.saved.as_ref().and_then(|t| t.cache.frames.get(&f)) {
|
||
return Some(FrameTrust { confidence: fr.c, occluded: fr.o });
|
||
}
|
||
trust(*self.keys.get(&f)?)
|
||
}
|
||
|
||
/// The model the saved tracking was made with, while it applies.
|
||
pub fn saved_model(&self) -> Option<&str> {
|
||
self.saved.as_ref().map(|t| t.cache.model.as_str())
|
||
}
|
||
|
||
/// Frame `f` of the saved tracking at `scale` (decoded once, then cached).
|
||
fn saved_seg(&self, f: i64, scale: f64) -> Option<Arc<FrameSeg>> {
|
||
let t = self.saved.as_ref()?;
|
||
let k = tracked_key(*t.keys.get(&f)?);
|
||
if let Some(s) = cached(k, scale) {
|
||
return Some(s);
|
||
}
|
||
let full = match cached(k, 1.0) {
|
||
Some(s) => s,
|
||
None => {
|
||
let s = Arc::new(decode_tracked(&t.cache, f)?);
|
||
store(k, 1.0, s.clone());
|
||
s
|
||
}
|
||
};
|
||
if scale == 1.0 {
|
||
return Some(full);
|
||
}
|
||
let s = Arc::new(downscale(&full, scale));
|
||
store(k, scale, s.clone());
|
||
Some(s)
|
||
}
|
||
|
||
/// Frame `f`'s segmentation at this chain's scale: cached, or computed from the nearest
|
||
/// cached frame towards the base (full-resolution results serve smaller scales).
|
||
/// `frame(g)`: the effect's input on frame g on the layer grid at this scale.
|
||
pub fn ensure(&self, f: i64, frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>, cancel: &dyn Fn() -> bool) -> Option<Arc<FrameSeg>> {
|
||
let sc = self.opts.scale;
|
||
let lookup = |g: i64| -> Option<Arc<FrameSeg>> {
|
||
// Show Object: one object's matte, where the tracker followed the objects.
|
||
if let Some(s) = self.shown_object(g) {
|
||
return Some(s);
|
||
}
|
||
// A frame the tracker followed wins over the classic engine's: the saved tracking, else
|
||
// this session's.
|
||
if let Some(s) = self.saved_seg(g, sc) {
|
||
return Some(s);
|
||
}
|
||
let k = *self.keys.get(&g)?;
|
||
if let Some(s) = cached(tracked_key(k), sc) {
|
||
return Some(s);
|
||
}
|
||
if let Some(s) = cached(k, sc).or_else(|| from_store(k, sc)) {
|
||
return Some(s);
|
||
}
|
||
if sc != 1.0 {
|
||
let full = cached(tracked_key(k), 1.0).or_else(|| 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);
|
||
}
|
||
None
|
||
};
|
||
if let Some(s) = lookup(f) {
|
||
return Some(s);
|
||
}
|
||
let base = self.data.base?;
|
||
if !self.data.in_span(f) {
|
||
return None;
|
||
}
|
||
// Walk towards the base to the nearest available frame.
|
||
let mut path = vec![f];
|
||
let mut g = f;
|
||
let mut start: Option<Arc<FrameSeg>> = None;
|
||
while let Some(src) = self.data.source_of(g) {
|
||
if let Some(s) = lookup(src) {
|
||
start = Some(s);
|
||
break;
|
||
}
|
||
path.push(src);
|
||
g = src;
|
||
}
|
||
path.reverse();
|
||
let mut prev_img: Option<Arc<Image>> = None;
|
||
let mut cur = start;
|
||
for g in path {
|
||
if cancel() {
|
||
return None;
|
||
}
|
||
let img = frame(g)?;
|
||
let strokes = self.data.strokes_at(g);
|
||
let seg = match (&cur, g == base) {
|
||
(_, true) | (None, _) => rb::segment_with(&img, &strokes, None, &self.opts, self.refine_radius, self.model.as_deref()),
|
||
(Some(p), false) => {
|
||
let src = self.data.source_of(g)?;
|
||
let pimg = match prev_img.take() {
|
||
Some(i) => i,
|
||
None => frame(src)?,
|
||
};
|
||
if pimg.width != img.width || pimg.height != img.height {
|
||
return None;
|
||
}
|
||
// A tracker is too slow to prompt on every frame (its pass is `track`); the classic flow carries the rest.
|
||
rb::propagate_with(&pimg, p, &img, &strokes, &self.opts, self.refine_radius, if self.has_tracker() { None } else { self.model.as_deref() })
|
||
}
|
||
};
|
||
let seg = Arc::new(seg);
|
||
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);
|
||
}
|
||
cur
|
||
}
|
||
|
||
/// Whether frame `f` is computed (at full resolution or at `scale`): in this session's caches,
|
||
/// or in the tracking saved with the project, which needs no work to be used even before it has
|
||
/// been decoded (and whichever model Settings choose now).
|
||
pub fn is_cached(&self, f: i64, scale: f64) -> bool {
|
||
self.keys.get(&f).is_some_and(|k| [*k, tracked_key(*k)].iter().any(|k| cached(*k, 1.0).is_some() || cached(*k, scale).is_some()))
|
||
|| self.saved.as_ref().is_some_and(|t| t.cache.frames.contains_key(&f) && t.keys.contains_key(&f))
|
||
}
|
||
|
||
/// Whether the tracking saved with the project has frame `f` (and applies to this chain).
|
||
pub fn saved_has(&self, f: i64) -> bool {
|
||
self.saved.as_ref().is_some_and(|t| t.cache.frames.contains_key(&f) && t.keys.contains_key(&f))
|
||
}
|
||
|
||
/// Whether the chosen model can follow an object through a clip (SAM 2).
|
||
pub fn has_tracker(&self) -> bool {
|
||
self.model.as_ref().is_some_and(|m| m.tracker().is_some())
|
||
}
|
||
|
||
/// Explicit propagation replaces tracking made by another model. Rendering and Freeze keep
|
||
/// using saved mattes even when that model is unavailable, so this is only called for Propagate.
|
||
pub fn prepare_propagation(&mut self) {
|
||
if self.saved_model().is_some_and(|saved| self.model.as_ref().is_none_or(|model| model.info().id != saved)) {
|
||
self.saved = None;
|
||
}
|
||
}
|
||
|
||
/// Discard a failed track completely before falling back to the classic engine.
|
||
pub fn forget_tracking(&mut self) {
|
||
let mut objects = self.data.objects();
|
||
if let Some(saved) = &self.saved {
|
||
objects.extend(saved.cache.objects.keys().copied());
|
||
objects.sort_unstable();
|
||
objects.dedup();
|
||
}
|
||
let keys = self.keys.values().chain(self.saved.iter().flat_map(|saved| saved.keys.values())).copied();
|
||
forget(keys.flat_map(|key| std::iter::once(key).chain(objects.iter().copied().map(move |object| object_key(key, object)))));
|
||
self.saved = None;
|
||
self.model = None;
|
||
}
|
||
|
||
/// Follow the object through the span with the model's tracker, from the strokes on their
|
||
/// frames (only the frames without a tracked result yet: after a correction stroke, the frames it
|
||
/// changes, tracked on from the nearest prompted frame), and cache every frame's segmentation (under the tracker's keys, with its
|
||
/// confidence). The strokes stay hard constraints and the edges are refined at full
|
||
/// resolution; frames the tracker can't give are left to [`Chain::ensure`]'s classic engine.
|
||
/// `progress` gets the number of frames finished. `Ok(None)`: no tracker, nothing done.
|
||
pub fn track(
|
||
&self,
|
||
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
|
||
cancel: &dyn Fn() -> bool,
|
||
progress: &mut dyn FnMut(usize),
|
||
) -> Result<Option<TrackSummary>, String> {
|
||
self.track_frames(None, frame, cancel, progress)
|
||
}
|
||
|
||
/// [`Chain::track`] limited to `wanted` frames (a one-direction Propagate): the window, the work and the
|
||
/// progress count cover only those frames of the span.
|
||
pub fn track_frames(
|
||
&self,
|
||
wanted: Option<&[i64]>,
|
||
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
|
||
cancel: &dyn Fn() -> bool,
|
||
progress: &mut dyn FnMut(usize),
|
||
) -> Result<Option<TrackSummary>, String> {
|
||
self.track_with_pending_limit(wanted, frame, cancel, progress, pending::MAX_PENDING_BYTES)
|
||
}
|
||
|
||
fn track_with_pending_limit(
|
||
&self,
|
||
wanted: Option<&[i64]>,
|
||
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
|
||
cancel: &dyn Fn() -> bool,
|
||
progress: &mut dyn FnMut(usize),
|
||
max_pending_bytes: usize,
|
||
) -> Result<Option<TrackSummary>, String> {
|
||
let Some(tracker) = self.model.as_ref().and_then(|m| m.tracker()) else { return Ok(None) };
|
||
let (Some(_), true) = (self.data.base, self.data.span[1] >= self.data.span[0]) else { return Err("no base frame".into()) };
|
||
// Frames of the span that already have a tracked result (this session's, or the saved
|
||
// tracking) are kept: only the rest is tracked. A correction stroke changes the keys of the
|
||
// frames from it on (both directions), so only those come back here.
|
||
let (lo_span, hi_span) = (self.data.span[0], self.data.span[1]);
|
||
let needed: Vec<i64> = (lo_span..=hi_span)
|
||
.filter(|f| wanted.is_none_or(|w| w.contains(f)))
|
||
.filter(|f| self.keys.get(f).is_some_and(|k| self.saved_seg(*f, self.opts.scale).is_none() && cached(tracked_key(*k), self.opts.scale).is_none()))
|
||
.collect();
|
||
let (Some(&need_lo), Some(&need_hi)) = (needed.first(), needed.last()) else {
|
||
return Ok(Some(TrackSummary::default()));
|
||
};
|
||
// Each object has its own prompts: a few clicks from its strokes and a box around what the classic engine
|
||
// makes of them on that frame (clicks alone pick a part of the object, not all of it).
|
||
let objects = self.data.objects();
|
||
let mut by_object: Vec<Vec<(i64, effectcraft_segment::Prompt)>> = vec![];
|
||
for &obj in &objects {
|
||
let mut prompts = vec![];
|
||
for f in self.data.stroke_frames().into_iter().filter(|f| self.data.in_span(*f)) {
|
||
let strokes = self.data.strokes_of(f, obj);
|
||
let p = match frame(f) {
|
||
Some(img) => rb::tracker_prompt_boxed(&strokes, self.opts.scale, &rb::segment(&img, &strokes, None, &self.opts, self.refine_radius)),
|
||
None => rb::tracker_prompt(&strokes, self.opts.scale),
|
||
};
|
||
if p.points.iter().any(|(_, fg)| *fg) {
|
||
prompts.push((f, p));
|
||
}
|
||
}
|
||
by_object.push(prompts);
|
||
}
|
||
// objects without a foreground prompt cannot be followed
|
||
let followed: Vec<(u32, Vec<(i64, effectcraft_segment::Prompt)>)> = objects.into_iter().zip(by_object).filter(|(_, p)| !p.is_empty()).collect();
|
||
if followed.is_empty() {
|
||
return Err("no foreground strokes to follow".into());
|
||
}
|
||
// The window to track: every object restarts at the nearest of its prompted frames at or before the first
|
||
// frame needed (a correction restarts the object there), so the window starts at the earliest restart; it
|
||
// ends at the last frame needed, reaching the farthest first prompt when an object's needed frames lie
|
||
// before its prompt.
|
||
let lo = followed.iter().map(|(_, p)| p.iter().map(|q| q.0).filter(|f| *f <= need_lo).max().unwrap_or(need_lo)).min().unwrap_or(need_lo);
|
||
let first_prompt = followed.iter().filter_map(|(_, p)| p.first().map(|q| q.0)).max().unwrap_or(need_hi);
|
||
let hi = need_hi.max(first_prompt);
|
||
let objects: Vec<u32> = followed.iter().map(|(o, _)| *o).collect();
|
||
let prompts: Vec<Vec<(i64, effectcraft_segment::Prompt)>> =
|
||
followed.into_iter().map(|(_, p)| p.into_iter().filter(|q| q.0 >= lo && q.0 <= hi).collect()).collect();
|
||
let req = TrackRequest { first: lo, count: (hi - lo + 1) as usize, objects: prompts };
|
||
let frame = std::cell::RefCell::new(frame);
|
||
let mut summary = TrackSummary::default();
|
||
// A frame is finished when every object has reported it (the objects' mattes are united).
|
||
let mut pending = pending::PendingMasks::new(objects.len(), max_pending_bytes);
|
||
let finish = |g: i64, parts: Vec<Option<effectcraft_segment::TrackedFrame>>, summary: &mut TrackSummary, progress: &mut dyn FnMut(usize)| {
|
||
if !wanted.is_none_or(|w| w.contains(&g)) {
|
||
return;
|
||
}
|
||
let (Some(key), Some(img)) = (self.keys.get(&g).copied(), (frame.borrow_mut())(g)) else { return };
|
||
let mut segs: Vec<(u32, effectcraft_track::roto::FrameSeg)> = vec![];
|
||
let (mut confidence, mut present) = (f32::INFINITY, false);
|
||
let mut worst = f32::INFINITY;
|
||
for (obj, t) in objects.iter().zip(parts) {
|
||
let Some(t) = t else { continue };
|
||
let strokes = self.data.strokes_of(g, *obj);
|
||
segs.push((*obj, rb::segment_tracked(&img, &strokes, t.prob, t.occluded, &self.opts, self.refine_radius)));
|
||
worst = worst.min(t.confidence);
|
||
if !t.occluded {
|
||
present = true;
|
||
confidence = confidence.min(t.confidence);
|
||
}
|
||
}
|
||
if segs.is_empty() {
|
||
return;
|
||
}
|
||
// a frame is occluded when every object is absent; otherwise the doubtful object sets the confidence
|
||
let (occluded, confidence) = (!present, if present { confidence } else { worst });
|
||
// with several objects each keeps its own matte too (Show Object, the per-object overlay)
|
||
let several = objects.len() > 1;
|
||
let mut parts = Vec::with_capacity(segs.len());
|
||
for (obj, seg) in segs {
|
||
if several {
|
||
store(object_key(key, obj), self.opts.scale, Arc::new(seg.clone()));
|
||
}
|
||
parts.push(seg);
|
||
}
|
||
store(tracked_key(key), self.opts.scale, Arc::new(effectcraft_track::roto::FrameSeg::union(parts)));
|
||
trust_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(tracked_key(key), FrameTrust { confidence, occluded });
|
||
summary.frames += 1;
|
||
summary.occluded += usize::from(occluded);
|
||
summary.min_confidence = if summary.frames == 1 { confidence } else { summary.min_confidence.min(confidence) };
|
||
progress(summary.frames);
|
||
};
|
||
let pending_failed = std::cell::Cell::new(false);
|
||
let mut pending_error = None;
|
||
let result = tracker.track(
|
||
&req,
|
||
&mut |g| {
|
||
let img = (frame.borrow_mut())(g)?;
|
||
Some(Arc::new(ClipFrame { rgb: rb::rgb_of(&img), w: img.width as usize, h: img.height as usize }))
|
||
},
|
||
&|| cancel() || pending_failed.get(),
|
||
&mut |o, g, t| {
|
||
// A tracker that reports object-by-object can otherwise retain a full clip of
|
||
// probability maps. Stop buffering immediately, even if it ignores cancellation.
|
||
if pending_failed.get() {
|
||
return;
|
||
}
|
||
match pending.insert(o, g, t) {
|
||
Ok(Some(parts)) => finish(g, parts, &mut summary, progress),
|
||
Ok(None) => {}
|
||
Err(e) => {
|
||
pending_error = Some(e);
|
||
pending_failed.set(true);
|
||
}
|
||
}
|
||
},
|
||
);
|
||
if let Some(e) = pending_error {
|
||
return Err(e);
|
||
}
|
||
result?;
|
||
// frames some object never reported (it was not followed there): united from the rest
|
||
for (g, parts) in pending.into_incomplete() {
|
||
finish(g, parts, &mut summary, progress);
|
||
}
|
||
Ok(Some(summary))
|
||
}
|
||
}
|
||
|
||
/// Each object's matte of layer frame `f` where the tracker followed several objects (full resolution; computes
|
||
/// nothing). Show Object limits it to the one chosen. Empty with fewer than two objects.
|
||
pub fn object_mattes(params: &Params, layer_size: [f64; 2], fps: f64, f: i64) -> Vec<(u32, Arc<FrameSeg>)> {
|
||
let mut chain = Chain::new(params, layer_size, fps, 1.0);
|
||
let objects = chain.data.objects();
|
||
if objects.len() < 2 || !chain.data.in_span(f) {
|
||
return vec![];
|
||
}
|
||
let shown = chain.show_object.checked_sub(1);
|
||
chain.show_object = 0;
|
||
objects.into_iter().filter(|o| shown.is_none_or(|s| s == *o)).filter_map(|o| chain.object_seg(f, o).map(|s| (o, s))).collect()
|
||
}
|
||
|
||
/// Nearest-neighbour resampling of a segmentation to another scale.
|
||
pub fn downscale(s: &FrameSeg, scale: f64) -> FrameSeg {
|
||
let (w, h) = (((s.w as f64) * scale).round().max(1.0) as usize, ((s.h as f64) * scale).round().max(1.0) as usize);
|
||
resample(s, w, h)
|
||
}
|
||
|
||
pub fn resample(s: &FrameSeg, w: usize, h: usize) -> FrameSeg {
|
||
if s.w == w && s.h == h {
|
||
return s.clone();
|
||
}
|
||
let mut out = FrameSeg { w, h, matte: vec![0; w * h], refine: vec![0; w * h], fg: s.fg.clone(), bg: s.bg.clone() };
|
||
if s.w == 0 || s.h == 0 {
|
||
return out;
|
||
}
|
||
for y in 0..h {
|
||
let sy = (((y as f64 + 0.5) * s.h as f64 / h as f64) as usize).min(s.h - 1);
|
||
for x in 0..w {
|
||
let sx = (((x as f64 + 0.5) * s.w as f64 / w as f64) as usize).min(s.w - 1);
|
||
out.matte[y * w + x] = s.matte[sy * s.w + sx];
|
||
out.refine[y * w + x] = s.refine.get(sy * s.w + sx).copied().unwrap_or(0);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The final matte of frame `seg` (with its neighbours), on its own grid.
|
||
pub fn final_alpha(
|
||
img: &Image,
|
||
seg: &FrameSeg,
|
||
prev: Option<&FrameSeg>,
|
||
next: Option<&FrameSeg>,
|
||
mp: &MatteParams,
|
||
refine_radius: f64,
|
||
scale: f64,
|
||
) -> Vec<f32> {
|
||
let rgb = rb::rgb_of(img);
|
||
refine::matte_alpha(&rgb, seg, prev, next, mp, refine_radius * scale, scale)
|
||
}
|
||
|
||
fn needs_neighbours(mp: &MatteParams) -> bool {
|
||
mp.rb_chatter > 0.0 || mp.chatter > 0.0 || mp.motion_blur
|
||
}
|
||
|
||
// ------------------------------------------------------------------ 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`].
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct FrozenCache {
|
||
pub key: String,
|
||
pub size: [usize; 2],
|
||
pub frames: BTreeMap<i64, FrozenFrame>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct FrozenFrame {
|
||
/// Alpha (RLE, base64).
|
||
pub a: String,
|
||
/// Refine Edge band (RLE, base64).
|
||
pub b: String,
|
||
}
|
||
|
||
impl FrozenCache {
|
||
pub fn from_json(s: &str) -> Option<FrozenCache> {
|
||
if s.trim().is_empty() {
|
||
return None;
|
||
}
|
||
serde_json::from_str(s).ok()
|
||
}
|
||
pub fn to_json(&self) -> String {
|
||
serde_json::to_string(self).unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
/// What frozen mattes depend on: every chain key 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);
|
||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||
for (f, k) in &ch.keys {
|
||
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());
|
||
}
|
||
format!("{h:016x}")
|
||
}
|
||
|
||
/// Whether the instance is frozen (has frozen mattes).
|
||
pub fn is_frozen(params: &Params) -> bool {
|
||
!s(params, FROZEN).is_empty()
|
||
}
|
||
|
||
type FrozenParsed = Mutex<HashMap<u64, Arc<FrozenCache>>>;
|
||
|
||
fn frozen(params: &Params) -> Option<Arc<FrozenCache>> {
|
||
static C: OnceLock<FrozenParsed> = OnceLock::new();
|
||
let j = s(params, FROZEN);
|
||
if j.is_empty() {
|
||
return None;
|
||
}
|
||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||
fnv(&mut h, j.as_bytes());
|
||
let c = C.get_or_init(Default::default);
|
||
if let Some(d) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&h) {
|
||
return Some(d.clone());
|
||
}
|
||
let d = Arc::new(FrozenCache::from_json(j)?);
|
||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||
if m.len() > 8 {
|
||
m.clear();
|
||
}
|
||
m.insert(h, d.clone());
|
||
Some(d)
|
||
}
|
||
|
||
// ------------------------------------------------------------------ saved tracking
|
||
|
||
/// The tracker's frames of an instance, saved with the project (the hidden [`TRACKED`] parameter,
|
||
/// as [`FrozenCache`] is). They apply while `key` matches the instance's inputs ([`tracks_key`]),
|
||
/// whichever model Settings choose: a stroke or span change makes them stale, and the next Propagate
|
||
/// replaces them (with the classic engine, that leaves none).
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct TrackCache {
|
||
/// What the frames were tracked from ([`tracks_key`]).
|
||
pub key: String,
|
||
/// The tracker's model id.
|
||
pub model: String,
|
||
/// The mattes' width and height (the layer's size).
|
||
pub size: [usize; 2],
|
||
pub frames: BTreeMap<i64, TrackFrame>,
|
||
/// With several objects: each one's matte of every tracked frame (object id, then frame).
|
||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||
pub objects: BTreeMap<u32, BTreeMap<i64, ObjectFrame>>,
|
||
}
|
||
|
||
/// One object's matte and Refine Edge band of a tracked frame (RLE, base64).
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct ObjectFrame {
|
||
pub m: String,
|
||
pub r: String,
|
||
}
|
||
|
||
/// One tracked frame: its matte and Refine Edge band (RLE, base64), its colour models and the
|
||
/// tracker's trust in it.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct TrackFrame {
|
||
/// Matte (RLE, base64).
|
||
pub m: String,
|
||
/// Refine Edge band (RLE, base64).
|
||
pub r: String,
|
||
pub fg: Gmm,
|
||
pub bg: Gmm,
|
||
/// 0–1: the tracker's confidence.
|
||
pub c: f32,
|
||
/// The object is not in the frame.
|
||
pub o: bool,
|
||
}
|
||
|
||
impl TrackCache {
|
||
pub fn from_json(s: &str) -> Option<TrackCache> {
|
||
if s.trim().is_empty() {
|
||
return None;
|
||
}
|
||
serde_json::from_str(s).ok()
|
||
}
|
||
pub fn to_json(&self) -> String {
|
||
serde_json::to_string(self).unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
/// What saved tracking depends on: the chain keys of its frames (the inputs and the model).
|
||
pub fn tracks_key(keys: &BTreeMap<i64, u64>) -> String {
|
||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||
for (f, k) in keys {
|
||
fnv(&mut h, &f.to_le_bytes());
|
||
fnv(&mut h, &k.to_le_bytes());
|
||
}
|
||
format!("{h:016x}")
|
||
}
|
||
|
||
/// The saved tracking of `params`, parsed once per content.
|
||
fn saved_tracks(params: &Params) -> Option<Arc<TrackCache>> {
|
||
static C: OnceLock<Mutex<HashMap<u64, Arc<TrackCache>>>> = OnceLock::new();
|
||
let j = s(params, TRACKED);
|
||
if j.is_empty() {
|
||
return None;
|
||
}
|
||
let mut h = 0xcbf2_9ce4_8422_2325;
|
||
fnv(&mut h, j.as_bytes());
|
||
let c = C.get_or_init(Default::default);
|
||
if let Some(d) = c.lock().unwrap_or_else(|e| e.into_inner()).get(&h) {
|
||
return Some(d.clone());
|
||
}
|
||
let d = Arc::new(TrackCache::from_json(j)?);
|
||
let mut m = c.lock().unwrap_or_else(|e| e.into_inner());
|
||
if m.len() > 8 {
|
||
m.clear();
|
||
}
|
||
m.insert(h, d.clone());
|
||
Some(d)
|
||
}
|
||
|
||
/// A saved tracking that applies to a chain: its frames, keyed under the model that made them.
|
||
pub(crate) struct SavedTracking {
|
||
cache: Arc<TrackCache>,
|
||
keys: BTreeMap<i64, u64>,
|
||
}
|
||
|
||
/// The saved tracking of `params` if it applies: the inputs are the instance's now. It does not depend
|
||
/// on which model Settings choose or has loaded, so a project renders the same anywhere.
|
||
fn saved_for(params: &Params, data: &RotoData, layer_size: [f64; 2], fps: f64) -> Option<SavedTracking> {
|
||
let cache = saved_tracks(params)?;
|
||
let keys = data.chain_keys(seed_with(params, layer_size, fps, &cache.model));
|
||
(tracks_key(&keys) == cache.key).then_some(SavedTracking { cache, keys })
|
||
}
|
||
|
||
/// Decode a tracked frame of saved tracking (full resolution).
|
||
fn decode_tracked(cache: &TrackCache, f: i64) -> Option<FrameSeg> {
|
||
let fr = cache.frames.get(&f)?;
|
||
// The size comes from the project file: checked, so a corrupt one is ignored rather than overflowing.
|
||
let [w, h] = cache.size;
|
||
let n = w.checked_mul(h)?;
|
||
if n == 0 || n > rle::MAX_PLANE_BYTES {
|
||
return None;
|
||
}
|
||
let matte = rle::decode_bounded(&fr.m, n)?;
|
||
let refine = rle::decode_bounded(&fr.r, n)?;
|
||
(matte.len() == n && refine.len() == n).then(|| FrameSeg { w, h, matte, refine, fg: fr.fg.clone(), bg: fr.bg.clone() })
|
||
}
|
||
|
||
/// Object `o`'s matte of a tracked frame of saved tracking (full resolution).
|
||
fn decode_object(cache: &TrackCache, f: i64, o: u32) -> Option<FrameSeg> {
|
||
let fr = cache.objects.get(&o)?.get(&f)?;
|
||
let [w, h] = cache.size;
|
||
let n = w.checked_mul(h)?;
|
||
if n == 0 || n > rle::MAX_PLANE_BYTES {
|
||
return None;
|
||
}
|
||
let matte = rle::decode_bounded(&fr.m, n)?;
|
||
let refine = rle::decode_bounded(&fr.r, n)?;
|
||
(matte.len() == n && refine.len() == n).then(|| FrameSeg { w, h, matte, refine, ..Default::default() })
|
||
}
|
||
|
||
/// The tracking a chain has just made, to save with its instance: every frame of its span that the
|
||
/// tracker followed, at full resolution, with its trust. `None` when it followed none.
|
||
pub fn tracks_of(ch: &Chain) -> Option<TrackCache> {
|
||
let model = ch.model.as_ref()?.info().id;
|
||
// The saved tracking applies to this chain's frames when the same model made it: what it has and this session did
|
||
// not track again stays (a project reopened and propagated with nothing new to track must keep its tracking).
|
||
let saved = ch.saved.as_ref().filter(|t| t.cache.model == model);
|
||
let mut size = saved.map(|t| t.cache.size);
|
||
let mut frames = BTreeMap::new();
|
||
for (f, k) in &ch.keys {
|
||
if let (Some(seg), Some(t)) = (cached(tracked_key(*k), 1.0), trust(*k)) {
|
||
size.get_or_insert([seg.w, seg.h]);
|
||
frames.insert(
|
||
*f,
|
||
TrackFrame { m: rle::encode(&seg.matte), r: rle::encode(&seg.refine), fg: seg.fg.clone(), bg: seg.bg.clone(), c: t.confidence, o: t.occluded },
|
||
);
|
||
} else if let Some(kept) = saved.filter(|t| t.keys.get(f) == Some(k)).and_then(|t| t.cache.frames.get(f)) {
|
||
frames.insert(*f, kept.clone());
|
||
}
|
||
}
|
||
let mut objects: BTreeMap<u32, BTreeMap<i64, ObjectFrame>> = BTreeMap::new();
|
||
let ids = ch.data.objects();
|
||
if ids.len() > 1 {
|
||
for (f, k) in &ch.keys {
|
||
if !frames.contains_key(f) {
|
||
continue;
|
||
}
|
||
for o in &ids {
|
||
if let Some(seg) = cached(object_key(*k, *o), 1.0) {
|
||
objects.entry(*o).or_default().insert(*f, ObjectFrame { m: rle::encode(&seg.matte), r: rle::encode(&seg.refine) });
|
||
} else if let Some(kept) = saved.filter(|t| t.keys.get(f) == Some(k)).and_then(|t| t.cache.objects.get(o)).and_then(|m| m.get(f)) {
|
||
objects.entry(*o).or_default().insert(*f, kept.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let cache = TrackCache { key: tracks_key(&ch.keys), model: model.to_string(), size: size?, frames, objects };
|
||
(!cache.frames.is_empty()).then_some(cache)
|
||
}
|
||
|
||
/// Whether the tracking saved with `params` covers every one of `frames`: they need no model to be read.
|
||
pub fn saved_covers(params: &Params, layer_size: [f64; 2], fps: f64, frames: &[i64]) -> bool {
|
||
let chain = Chain::new(params, layer_size, fps, 1.0);
|
||
!frames.is_empty() && frames.iter().all(|f| chain.saved_has(*f))
|
||
}
|
||
|
||
/// Encode one frame for [`FrozenCache`].
|
||
pub fn freeze_frame(alpha: &[f32], band: &[u8]) -> FrozenFrame {
|
||
let a: Vec<u8> = alpha.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect();
|
||
FrozenFrame { a: rle::encode(&a), b: rle::encode(band) }
|
||
}
|
||
|
||
// ------------------------------------------------------------------ render
|
||
|
||
/// A frame's matte (alpha and band) on the layer grid of `img` at `scale`.
|
||
fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc<Image>, scale: f64) -> Option<(Vec<f32>, Vec<u8>, f64)> {
|
||
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())
|
||
&& let Some(fr) = fc.frames.get(&f)
|
||
{
|
||
let [fw, fh] = fc.size;
|
||
let expected = fw.checked_mul(fh)?;
|
||
if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES {
|
||
return None;
|
||
}
|
||
let a = rle::decode_bounded(&fr.a, expected)?;
|
||
let bnd = rle::decode_bounded(&fr.b, expected)?;
|
||
if a.len() == expected && bnd.len() == expected {
|
||
let af: Vec<f32> = a.iter().map(|v| *v as f32 / 255.0).collect();
|
||
let (alpha, band) = if fw == w && fh == h {
|
||
(af, bnd)
|
||
} else {
|
||
let mut al = vec![0.0f32; w * h];
|
||
let mut bd = vec![0u8; w * h];
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let (sx, sy) = ((x as f64 + 0.5) * fw as f64 / w as f64, (y as f64 + 0.5) * fh as f64 / h as f64);
|
||
al[y * w + x] = rb::matting::sample(&af, fw, fh, sx, sy);
|
||
bd[y * w + x] = bnd[(sy as usize).min(fh - 1) * fw + (sx as usize).min(fw - 1)];
|
||
}
|
||
}
|
||
(al, bd)
|
||
};
|
||
let rr = data(ctx.params).refine_radius();
|
||
return Some((alpha, band, rr));
|
||
}
|
||
}
|
||
let host = ctx.env.host;
|
||
let chain = Chain::new(ctx.params, ctx.layer_size, ctx.fps(), scale);
|
||
let fps = ctx.fps();
|
||
let effects = ctx.env.effect_index;
|
||
let layer_size = ctx.layer_size;
|
||
let me = img.clone();
|
||
let mut frame = |g: i64| -> Option<Arc<Image>> {
|
||
if g == f {
|
||
return Some(me.clone());
|
||
}
|
||
let buf = host?.self_at(g as f64 / fps, effects)?;
|
||
let im = layer_grid(&buf, layer_size);
|
||
(im.width as usize == w && im.height as usize == h).then(|| Arc::new(im))
|
||
};
|
||
let seg = chain.ensure(f, &mut frame, &|| false)?;
|
||
let seg = if seg.w == w && seg.h == h { (*seg).clone() } else { resample(&seg, w, h) };
|
||
let mp = matte_params(ctx.params);
|
||
let (mut prev, mut next) = (None, None);
|
||
if needs_neighbours(&mp) {
|
||
for (slot, g) in [(&mut prev, f - 1), (&mut next, f + 1)] {
|
||
if chain.data.in_span(g)
|
||
&& let Some(s) = chain.ensure(g, &mut frame, &|| false)
|
||
{
|
||
*slot = Some(if s.w == w && s.h == h { (*s).clone() } else { resample(&s, w, h) });
|
||
}
|
||
}
|
||
}
|
||
let alpha = final_alpha(img, &seg, prev.as_ref(), next.as_ref(), &mp, chain.refine_radius, scale);
|
||
Some((alpha, seg.refine, chain.refine_radius))
|
||
}
|
||
|
||
fn render(ctx: &EffectCtx, buf: Buf) -> Buf {
|
||
let d = data(ctx.params);
|
||
if d.is_empty() || ctx.adjustment {
|
||
return buf;
|
||
}
|
||
let f = frame_of(ctx.time, ctx.fps());
|
||
let sc = if buf.scale > 0.0 { buf.scale } else { 1.0 };
|
||
let mut out = Buf { img: Image::new(buf.img.width, buf.img.height), offset: buf.offset, scale: buf.scale };
|
||
if !d.in_span(f) {
|
||
return out;
|
||
}
|
||
let img = Arc::new(layer_grid(&buf, ctx.layer_size));
|
||
let Some((alpha, band, rr)) = matte_for(ctx, f, &img, sc) else { return out };
|
||
let (w, h) = (img.width as usize, img.height as usize);
|
||
let mp = matte_params(ctx.params);
|
||
let mut rgb = rb::rgb_of(&img);
|
||
let map = refine::decontaminate(&mut rgb, &alpha, &band, (w, h), &mp, rr * sc, sc);
|
||
let view_map = b(ctx.params, "refineEdgeMatte/decontamination/viewDecontaminationMap");
|
||
let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let (bx, by) = (x as i64 + ox, y as i64 + oy);
|
||
if bx < 0 || by < 0 || bx >= out.img.width as i64 || by >= out.img.height as i64 {
|
||
continue;
|
||
}
|
||
let i = y * w + x;
|
||
let a_in = img.data[i][3];
|
||
let px = if view_map {
|
||
let v = map[i];
|
||
[v, v, v, 1.0]
|
||
} else {
|
||
let a = alpha[i] * a_in;
|
||
[rgb[i][0] * a, rgb[i][1] * a, rgb[i][2] * a, a]
|
||
};
|
||
out.img.set(bx as u32, by as u32, px);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The final matte of the effect at layer frame `f` on the full-resolution layer grid of
|
||
/// `input` (the effect's input buffer at scale 1), computing what is missing. For Freeze and
|
||
/// the Layer panel overlays.
|
||
pub fn frame_matte(
|
||
params: &Params,
|
||
layer_size: [f64; 2],
|
||
fps: f64,
|
||
f: i64,
|
||
frame: &mut dyn FnMut(i64) -> Option<Arc<Image>>,
|
||
cancel: &dyn Fn() -> bool,
|
||
) -> Option<(Vec<f32>, Vec<u8>)> {
|
||
let chain = Chain::new(params, layer_size, fps, 1.0);
|
||
let img = frame(f)?;
|
||
let seg = chain.ensure(f, frame, cancel)?;
|
||
let mp = matte_params(params);
|
||
let (mut prev, mut next) = (None, None);
|
||
if needs_neighbours(&mp) {
|
||
if chain.data.in_span(f - 1) {
|
||
prev = chain.ensure(f - 1, frame, cancel);
|
||
}
|
||
if chain.data.in_span(f + 1) {
|
||
next = chain.ensure(f + 1, frame, cancel);
|
||
}
|
||
}
|
||
let alpha = final_alpha(&img, &seg, prev.as_deref(), next.as_deref(), &mp, chain.refine_radius, 1.0);
|
||
Some((alpha, seg.refine.clone()))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::{EffectEnv, EffectHost, LayerPixels};
|
||
|
||
/// A host serving a moving red disk over a blue/green background as the effect's input.
|
||
struct Seq;
|
||
fn frame(t: f64) -> Image {
|
||
let fi = (t * 25.0 + 1e-6).floor();
|
||
let c = [40.0 + 2.0 * fi, 40.0];
|
||
let mut img = Image::new(100, 80);
|
||
for y in 0..80 {
|
||
for x in 0..100 {
|
||
let d = ((x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1])) - 15.0;
|
||
let a = (0.5 - d).clamp(0.0, 1.0) as f32;
|
||
let tex = ((x * 7 + y * 13) % 9) as f32 / 60.0;
|
||
let fg = [0.85 + tex, 0.2, 0.1];
|
||
let bg = [0.1, 0.4 + tex, 0.7];
|
||
img.set(x, y, [fg[0] * a + bg[0] * (1.0 - a), fg[1] * a + bg[1] * (1.0 - a), fg[2] * a + bg[2] * (1.0 - a), 1.0]);
|
||
}
|
||
}
|
||
img
|
||
}
|
||
impl EffectHost for Seq {
|
||
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
|
||
None
|
||
}
|
||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||
None
|
||
}
|
||
fn self_at(&self, t: f64, _: usize) -> Option<Buf> {
|
||
Some(Buf { img: frame(t), offset: [0.0; 2], scale: 1.0 })
|
||
}
|
||
}
|
||
|
||
fn params_with(d: &RotoData) -> Params {
|
||
let s = &specs()[0];
|
||
let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||
params.values.insert(STROKES.into(), Value::Str(d.to_json()));
|
||
params
|
||
}
|
||
|
||
#[test]
|
||
fn renders_the_segmented_disk_on_demand() {
|
||
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 }, 20, [0, 24]);
|
||
d.add(Stroke { kind: StrokeKind::Bg, frame: 0, radius: 3.0, points: vec![[5.0, 5.0], [95.0, 5.0], [95.0, 75.0]], object: 0 }, 20, [0, 24]);
|
||
let params = params_with(&d);
|
||
let host = Seq;
|
||
let run = |t: f64| {
|
||
let ctx = EffectCtx {
|
||
params: ¶ms,
|
||
time: t,
|
||
layer_size: [100.0, 80.0],
|
||
seed: 1,
|
||
adjustment: false,
|
||
env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() },
|
||
};
|
||
crate::apply(&specs()[0], &ctx, Buf { img: frame(t), offset: [0.0; 2], scale: 1.0 }).img
|
||
};
|
||
// Frame 5 (propagated through frames 1–4).
|
||
let out = run(5.0 / 25.0 + 0.001);
|
||
let c = [50.0, 40.0];
|
||
let mut wrong = 0;
|
||
for y in 0..80u32 {
|
||
for x in 0..100u32 {
|
||
let d = (x as f64 + 0.5 - c[0]).hypot(y as f64 + 0.5 - c[1]);
|
||
let a = out.data[(y * 100 + x) as usize][3];
|
||
if (d < 14.0 && a < 0.5) || (d > 16.0 && a > 0.5) {
|
||
wrong += 1;
|
||
}
|
||
}
|
||
}
|
||
assert!(wrong < 20, "{wrong} wrong pixels");
|
||
// Outside the span: transparent.
|
||
let mut d2 = (*data(¶ms)).clone();
|
||
d2.span = [0, 3];
|
||
let p2 = params_with(&d2);
|
||
let ctx = EffectCtx {
|
||
params: &p2,
|
||
time: 0.3,
|
||
layer_size: [100.0, 80.0],
|
||
seed: 1,
|
||
adjustment: false,
|
||
env: EffectEnv { host: Some(&host), frame_rate: 25.0, ..Default::default() },
|
||
};
|
||
let o = crate::apply(&specs()[0], &ctx, Buf { img: frame(0.3), offset: [0.0; 2], scale: 1.0 }).img;
|
||
assert!(o.data.iter().all(|p| p[3] == 0.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<Vec<Vec<usize>>>);
|
||
impl effectcraft_segment::MaskModel for TwoObjects {
|
||
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
|
||
&effectcraft_segment::SAM2_TINY
|
||
}
|
||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||
Err("no weights".into())
|
||
}
|
||
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
|
||
Some(self)
|
||
}
|
||
}
|
||
impl effectcraft_segment::ObjectTracker for TwoObjects {
|
||
fn track(
|
||
&self,
|
||
req: &effectcraft_segment::TrackRequest,
|
||
frame: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
|
||
_: &dyn Fn() -> bool,
|
||
out: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame),
|
||
) -> effectcraft_segment::Result<()> {
|
||
self.0.lock().unwrap().push(req.objects.iter().map(Vec::len).collect());
|
||
for g in req.first..req.first + req.count as i64 {
|
||
let img = frame(g).ok_or("no frame")?;
|
||
let c = [40.0 + 2.0 * g as f64, 40.0];
|
||
// the objects report in reverse order on odd frames: the union must not depend on it
|
||
let order: Vec<usize> = if g % 2 == 1 { (0..req.objects.len()).rev().collect() } else { (0..req.objects.len()).collect() };
|
||
for o in order {
|
||
let prob = (0..img.w * img.h)
|
||
.map(|i| {
|
||
let (x, y) = ((i % img.w) as f64 + 0.5, (i / img.w) as f64 + 0.5);
|
||
let inside = if o == 0 { (x - c[0]).hypot(y - c[1]) <= 15.0 } else { (8.0..22.0).contains(&x) && (8.0..22.0).contains(&y) };
|
||
f32::from(inside)
|
||
})
|
||
.collect();
|
||
out(o, g, effectcraft_segment::TrackedFrame { prob, w: img.w, h: img.h, confidence: if o == 0 { 0.9 } else { 0.4 }, occluded: false });
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// The ordering SAM 2 uses when objects are first prompted on different frames: each object
|
||
/// reports its entire span before the next. No weights or large allocations are needed.
|
||
#[derive(Default)]
|
||
struct SeparatePasses {
|
||
emitted: std::sync::atomic::AtomicUsize,
|
||
cancelled: std::sync::atomic::AtomicBool,
|
||
}
|
||
|
||
impl effectcraft_segment::MaskModel for SeparatePasses {
|
||
fn info(&self) -> &'static effectcraft_segment::ModelInfo {
|
||
&effectcraft_segment::SAM2_TINY
|
||
}
|
||
fn segment(&self, _: &[[f32; 3]], _: usize, _: usize, _: &effectcraft_segment::Prompt) -> effectcraft_segment::Result<Vec<f32>> {
|
||
Err("no weights".into())
|
||
}
|
||
fn tracker(&self) -> Option<&dyn effectcraft_segment::ObjectTracker> {
|
||
Some(self)
|
||
}
|
||
}
|
||
|
||
impl effectcraft_segment::ObjectTracker for SeparatePasses {
|
||
fn track(
|
||
&self,
|
||
req: &effectcraft_segment::TrackRequest,
|
||
frames: &mut dyn FnMut(i64) -> Option<Arc<ClipFrame>>,
|
||
cancel: &dyn Fn() -> bool,
|
||
out: &mut dyn FnMut(usize, i64, effectcraft_segment::TrackedFrame),
|
||
) -> effectcraft_segment::Result<()> {
|
||
use std::sync::atomic::Ordering::Relaxed;
|
||
assert_ne!(req.objects[0][0].0, req.objects[1][0].0);
|
||
for o in 0..req.objects.len() {
|
||
for g in req.first..req.first + req.count as i64 {
|
||
if cancel() {
|
||
self.cancelled.store(true, Relaxed);
|
||
return Err("tracking cancelled".into());
|
||
}
|
||
let img = frames(g).ok_or("no frame")?;
|
||
self.emitted.fetch_add(1, Relaxed);
|
||
out(o, g, effectcraft_segment::TrackedFrame { prob: vec![0.5; img.w * img.h], w: img.w, h: img.h, confidence: 0.9, occluded: false });
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn separate_object_passes_cancel_at_the_pending_mask_budget() {
|
||
use std::sync::atomic::Ordering::Relaxed;
|
||
let mut d = RotoData::default();
|
||
for object in 0..2 {
|
||
d.add(Stroke { kind: StrokeKind::Fg, frame: i64::from(object), radius: 3.0, points: vec![[35.0, 40.0]], object }, 8, [0, 24]);
|
||
}
|
||
let mut params = params_with(&d);
|
||
params.values.insert(INPUT_KEY.into(), Value::Str("pending-budget-regression".into()));
|
||
let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0);
|
||
let fake = Arc::new(SeparatePasses::default());
|
||
chain.model = Some(fake.clone());
|
||
let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0)));
|
||
let mut completed = 0;
|
||
// Each map takes 32,000 bytes; two incomplete frames exceed this small test budget.
|
||
let error = chain.track_with_pending_limit(None, &mut get, &|| false, &mut |n| completed = n, 64_000).unwrap_err();
|
||
assert!(error.contains("pending object masks") && error.contains("memory budget"), "{error}");
|
||
assert!(fake.cancelled.load(Relaxed), "the model must see cancellation before allocating the rest of the clip");
|
||
assert_eq!(fake.emitted.load(Relaxed), 2);
|
||
assert_eq!(completed, 0, "partial objects are not complete tracked frames");
|
||
}
|
||
|
||
/// A cold chain with saved tracking and the same model keeps the saved frames when asked what to save (nothing
|
||
/// was tracked this session), and Show Object reads a frame the saved tracking has under its own keys.
|
||
#[test]
|
||
fn saved_tracking_is_kept_and_its_objects_stay_out_of_another_models_keys() {
|
||
let mut d = RotoData::default();
|
||
d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0]], object: 0 }, 2, [0, 24]);
|
||
let mut params = params_with(&d);
|
||
params.values.insert(INPUT_KEY.into(), Value::Str("keep-saved-and-objects".into()));
|
||
let size = [100.0, 80.0];
|
||
let model = effectcraft_segment::SAM2_TINY.id;
|
||
let keys = d.chain_keys(seed_with(¶ms, size, 25.0, model));
|
||
let (matte, empty) = (vec![1u8; 100 * 80], vec![0u8; 100 * 80]);
|
||
let frame = TrackFrame { m: rle::encode(&matte), r: rle::encode(&empty), c: 0.9, ..Default::default() };
|
||
let object = ObjectFrame { m: rle::encode(&matte), r: rle::encode(&empty) };
|
||
let saved = TrackCache {
|
||
key: tracks_key(&keys),
|
||
model: model.into(),
|
||
size: [100, 80],
|
||
frames: keys.keys().map(|f| (*f, frame.clone())).collect(),
|
||
objects: [(1, keys.keys().map(|f| (*f, object.clone())).collect())].into_iter().collect(),
|
||
};
|
||
params.values.insert(TRACKED.into(), Value::Str(saved.to_json()));
|
||
let mut chain = Chain::new(¶ms, size, 25.0, 1.0);
|
||
chain.model = Some(Arc::new(TwoObjects(Default::default())));
|
||
chain.keys = keys.clone();
|
||
let kept = tracks_of(&chain).expect("the saved tracking is kept");
|
||
assert_eq!(kept.frames.keys().collect::<Vec<_>>(), saved.frames.keys().collect::<Vec<_>>());
|
||
assert_eq!(kept.frames.get(&1), saved.frames.get(&1));
|
||
// another model is chosen now: its keys differ, and the saved object matte is not stored under them
|
||
let other = d.chain_keys(seed_with(¶ms, size, 25.0, effectcraft_segment::SAM2_SMALL.id));
|
||
chain.keys = other.clone();
|
||
let seg = chain.object_seg(1, 1).expect("the saved object matte");
|
||
assert_eq!(seg.matte.len(), 100 * 80);
|
||
assert!(cached(object_key(other[&1], 1), 1.0).is_none(), "the other model's cache holds none of it");
|
||
for key in [keys[&1], other[&1]] {
|
||
for scale in [1.0, 0.5] {
|
||
store(tracked_key(key), scale, seg.clone());
|
||
store(object_key(key, 1), scale, seg.clone());
|
||
}
|
||
trust_cache().lock().unwrap().insert(tracked_key(key), FrameTrust { confidence: 0.9, occluded: false });
|
||
}
|
||
chain.forget_tracking();
|
||
assert!(chain.model.is_none());
|
||
assert!(!chain.saved_has(1));
|
||
for key in [keys[&1], other[&1]] {
|
||
for scale in [1.0, 0.5] {
|
||
assert!(cached(tracked_key(key), scale).is_none());
|
||
assert!(cached(object_key(key, 1), scale).is_none());
|
||
}
|
||
assert!(trust(key).is_none());
|
||
}
|
||
assert!(chain.ensure(1, &mut |_| None, &|| false).is_none(), "neither saved nor in-memory tracking survives fallback");
|
||
}
|
||
|
||
#[test]
|
||
fn showing_a_missing_tracked_object_never_falls_back_to_other_objects() {
|
||
let mut d = RotoData::default();
|
||
for object in 0..2 {
|
||
d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 1.0, points: vec![[1.0, 1.0]], object }, 1, [0, 1]);
|
||
}
|
||
let union = Arc::new(FrameSeg { w: 4, h: 2, matte: vec![255; 8], refine: vec![0; 8], ..Default::default() });
|
||
for saved in [false, true] {
|
||
for scale in [1.0, 0.5] {
|
||
let mut params = params_with(&d);
|
||
params.values.insert("version".into(), Value::Scalar(0.0));
|
||
params.values.insert(INPUT_KEY.into(), Value::Str(format!("missing-object-{saved}-{scale}")));
|
||
let keys = d.chain_keys(seed(¶ms, [4.0, 2.0], 25.0));
|
||
if saved {
|
||
let matte = rle::encode(&union.matte);
|
||
let refine = rle::encode(&union.refine);
|
||
let cache = TrackCache {
|
||
key: tracks_key(&keys),
|
||
model: effectcraft_segment::CLASSICAL.into(),
|
||
size: [4, 2],
|
||
frames: [(0, TrackFrame { m: matte.clone(), r: refine.clone(), ..Default::default() })].into_iter().collect(),
|
||
objects: [(0, [(0, ObjectFrame { m: matte, r: refine })].into_iter().collect())].into_iter().collect(),
|
||
};
|
||
params.values.insert(TRACKED.into(), Value::Str(cache.to_json()));
|
||
} else {
|
||
store(tracked_key(keys[&0]), 1.0, union.clone());
|
||
store(object_key(keys[&0], 0), 1.0, union.clone());
|
||
}
|
||
let mut chain = Chain::new(¶ms, [4.0, 2.0], 25.0, scale);
|
||
let expected = downscale(&union, scale);
|
||
for object in [2, 7] {
|
||
chain.show_object = object;
|
||
let shown = chain.ensure(0, &mut |_| None, &|| false).expect("tracked frame");
|
||
assert_eq!((shown.w, shown.h, shown.area()), (expected.w, expected.h, 0), "saved={saved}, scale={scale}, object={object}");
|
||
}
|
||
assert!(chain.shown_object(1).is_none(), "untracked frames keep their existing fallback");
|
||
for object in [0, 1] {
|
||
chain.show_object = object;
|
||
assert_eq!(chain.ensure(0, &mut |_| None, &|| false).unwrap().matte, expected.matte);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn saved_tracking_decoders_validate_dimensions_and_plane_lengths() {
|
||
let valid = rle::encode(&[255, 0, 255, 0]);
|
||
let mut cache = TrackCache {
|
||
size: [2, 2],
|
||
frames: [(0, TrackFrame { m: valid.clone(), r: valid.clone(), ..Default::default() })].into_iter().collect(),
|
||
objects: [(1, [(0, ObjectFrame { m: valid.clone(), r: valid.clone() })].into_iter().collect())].into_iter().collect(),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(decode_tracked(&cache, 0).unwrap().matte, [255, 0, 255, 0]);
|
||
assert_eq!(decode_object(&cache, 0, 1).unwrap().refine, [255, 0, 255, 0]);
|
||
for (size, matte, refine) in [
|
||
([0, 2], rle::encode(&[]), rle::encode(&[])),
|
||
([2, 0], rle::encode(&[]), rle::encode(&[])),
|
||
([usize::MAX, 2], valid.clone(), valid.clone()),
|
||
([rle::MAX_PLANE_BYTES + 1, 1], valid.clone(), valid.clone()),
|
||
([2, 2], rle::encode(&[0; 5]), valid.clone()),
|
||
([2, 2], valid.clone(), rle::encode(&[0; 5])),
|
||
([2, 2], rle::encode(&[0; 3]), valid.clone()),
|
||
([2, 2], valid, rle::encode(&[0; 3])),
|
||
] {
|
||
cache.size = size;
|
||
cache.frames.insert(0, TrackFrame { m: matte.clone(), r: refine.clone(), ..Default::default() });
|
||
cache.objects.insert(1, [(0, ObjectFrame { m: matte, r: refine })].into_iter().collect());
|
||
assert!(decode_tracked(&cache, 0).is_none(), "tracked plane {size:?}");
|
||
assert!(decode_object(&cache, 0, 1).is_none(), "object plane {size:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_one_direction_pass_tracks_and_reports_only_its_frames() {
|
||
let mut d = RotoData::default();
|
||
d.add(Stroke { kind: StrokeKind::Fg, frame: 12, radius: 3.0, points: vec![[35.0, 40.0]], object: 0 }, 12, [0, 24]);
|
||
let params = params_with(&d);
|
||
let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0);
|
||
chain.model = Some(Arc::new(TwoObjects(Default::default())));
|
||
let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0)));
|
||
let forward: Vec<i64> = (12..=24).collect();
|
||
let mut last = 0;
|
||
let summary = chain.track_frames(Some(&forward), &mut get, &|| false, &mut |n| last = n).unwrap().unwrap();
|
||
assert_eq!((summary.frames, last), (forward.len(), forward.len()), "progress never exceeds the frames asked for");
|
||
let _ = chain.ensure(5, &mut get, &|| false);
|
||
assert!(tracks_of(&chain).is_none_or(|t| t.frames.keys().all(|f| *f >= 12)), "earlier frames were not tracked");
|
||
}
|
||
|
||
#[test]
|
||
fn propagation_replaces_saved_tracking_from_a_different_model() {
|
||
let mut d = RotoData::default();
|
||
d.add(Stroke { kind: StrokeKind::Fg, frame: 0, radius: 3.0, points: vec![[35.0, 40.0]], object: 0 }, 2, [0, 24]);
|
||
let mut params = params_with(&d);
|
||
params.values.insert(INPUT_KEY.into(), Value::Str("replace-saved-tracking-model".into()));
|
||
let size = [100.0, 80.0];
|
||
let old_model = effectcraft_segment::SAM2_SMALL.id;
|
||
let old_keys = d.chain_keys(seed_with(¶ms, size, 25.0, old_model));
|
||
let empty = vec![0; 100 * 80];
|
||
let old_frame = TrackFrame { m: rle::encode(&empty), r: rle::encode(&empty), c: 0.2, ..Default::default() };
|
||
let saved = TrackCache {
|
||
key: tracks_key(&old_keys),
|
||
model: old_model.into(),
|
||
size: [100, 80],
|
||
frames: old_keys.keys().map(|f| (*f, old_frame.clone())).collect(),
|
||
..Default::default()
|
||
};
|
||
params.values.insert(TRACKED.into(), Value::Str(saved.to_json()));
|
||
let mut chain = Chain::new(¶ms, size, 25.0, 1.0);
|
||
let fake = Arc::new(TwoObjects(Default::default()));
|
||
chain.model = Some(fake.clone());
|
||
chain.keys = d.chain_keys(seed_with(¶ms, size, 25.0, fake.info().id));
|
||
let mut get = |f: i64| Some(Arc::new(frame(f as f64 / 25.0)));
|
||
assert_eq!(chain.saved_model(), Some(old_model));
|
||
assert_eq!(chain.ensure(0, &mut get, &|| false).unwrap().area(), 0, "rendering keeps the saved matte until propagation");
|
||
chain.prepare_propagation();
|
||
assert_eq!(chain.saved_model(), None);
|
||
assert_eq!(chain.track(&mut get, &|| false, &mut |_| {}).unwrap().unwrap().frames, 3);
|
||
assert_eq!(fake.0.lock().unwrap().len(), 1, "the newly selected tracker must actually run");
|
||
assert!(chain.ensure(0, &mut get, &|| false).unwrap().area() > 0);
|
||
let replacement = tracks_of(&chain).expect("new tracking is saved");
|
||
assert_eq!(replacement.model, effectcraft_segment::SAM2_TINY.id);
|
||
assert_eq!(replacement.frames.len(), 3);
|
||
params.values.insert(TRACKED.into(), Value::Str(replacement.to_json()));
|
||
let mut reopened = Chain::new(¶ms, size, 25.0, 1.0);
|
||
assert_eq!(reopened.saved_model(), Some(effectcraft_segment::SAM2_TINY.id));
|
||
assert!(reopened.ensure(0, &mut get, &|| false).unwrap().area() > 0, "reopening uses the replacement without loading its model");
|
||
reopened.model = Some(fake.clone());
|
||
reopened.keys = chain.keys.clone();
|
||
reopened.prepare_propagation();
|
||
assert_eq!(reopened.saved_model(), Some(effectcraft_segment::SAM2_TINY.id));
|
||
assert_eq!(reopened.track(&mut get, &|| false, &mut |_| {}).unwrap().unwrap().frames, 0, "matching saved tracking is reused");
|
||
assert_eq!(fake.0.lock().unwrap().len(), 1);
|
||
let mut classic = Chain::new(¶ms, size, 25.0, 1.0);
|
||
classic.model = None;
|
||
classic.prepare_propagation();
|
||
assert_eq!(classic.saved_model(), None, "Classic propagation also replaces saved model tracking");
|
||
}
|
||
|
||
#[test]
|
||
fn objects_are_followed_in_one_pass_and_their_mattes_united() {
|
||
let mut d = RotoData::default();
|
||
let stroke = |kind, points: Vec<[f64; 2]>, object| Stroke { kind, frame: 0, radius: 3.0, points, object };
|
||
d.add(stroke(StrokeKind::Fg, vec![[35.0, 40.0], [45.0, 40.0]], 0), 8, [0, 24]);
|
||
d.add(stroke(StrokeKind::Fg, vec![[12.0, 15.0], [18.0, 15.0]], 1), 8, [0, 24]);
|
||
d.add(stroke(StrokeKind::Bg, vec![[85.0, 70.0], [95.0, 70.0]], 0), 8, [0, 24]);
|
||
assert_eq!(d.objects(), vec![0, 1]);
|
||
// the Refine Edge strokes belong to every object; the others only to their own
|
||
assert_eq!(d.strokes_of(0, 1).len(), 1);
|
||
assert_eq!(d.strokes_of(0, 0).len(), 2);
|
||
let params = params_with(&d);
|
||
let mut chain = Chain::new(¶ms, [100.0, 80.0], 25.0, 1.0);
|
||
let fake = Arc::new(TwoObjects(Default::default()));
|
||
chain.model = Some(fake.clone());
|
||
// the clip: the moving disk and a fixed yellow square
|
||
let mut get = |f: i64| {
|
||
let mut img = frame(f as f64 / 25.0 + 0.001);
|
||
for y in 8..22 {
|
||
for x in 8..22 {
|
||
img.set(x, y, [0.95, 0.9, 0.1 + ((x * 3 + y * 5) % 7) as f32 / 70.0, 1.0]);
|
||
}
|
||
}
|
||
Some(Arc::new(img))
|
||
};
|
||
let mut seen = 0;
|
||
let summary = chain.track(&mut get, &|| false, &mut |n| seen = n).unwrap().unwrap();
|
||
// one request for both objects; object 0 has its foreground and background prompt on the frame, object 1 its own
|
||
assert_eq!(fake.0.lock().unwrap().len(), 1);
|
||
assert_eq!(fake.0.lock().unwrap()[0], vec![1, 1]);
|
||
assert_eq!(summary.frames, 9);
|
||
assert_eq!(seen, 9);
|
||
// the least sure object sets the frame's confidence
|
||
assert!((summary.min_confidence - 0.4).abs() < 1e-6, "{}", summary.min_confidence);
|
||
assert_eq!(summary.occluded, 0);
|
||
let seg = cached(tracked_key(chain.keys[&3]), 1.0).expect("frame 3 is tracked");
|
||
let at = |x: usize, y: usize| seg.matte[y * 100 + x];
|
||
assert!(at(46, 40) != 0, "the moving disk (frame 3 centre x = 46)");
|
||
assert!(at(15, 15) != 0, "the fixed square");
|
||
assert_eq!(at(60, 70), 0, "nothing elsewhere");
|
||
|
||
// each object keeps its own matte
|
||
let (disk, square) = (chain.object_seg(3, 0).expect("object 0"), chain.object_seg(3, 1).expect("object 1"));
|
||
assert!(disk.matte[40 * 100 + 46] != 0 && disk.matte[15 * 100 + 15] == 0);
|
||
assert!(square.matte[15 * 100 + 15] != 0 && square.matte[40 * 100 + 46] == 0);
|
||
// Show Object picks one (its id plus one); an object the instance does not have shows nothing; 0 shows all
|
||
let mut shown = |c: &Chain| c.ensure(3, &mut get, &|| false).expect("frame 3");
|
||
chain.show_object = 2;
|
||
let one = shown(&chain);
|
||
assert!(one.matte[15 * 100 + 15] != 0 && one.matte[40 * 100 + 46] == 0, "only the square");
|
||
chain.show_object = 1;
|
||
let zero = shown(&chain);
|
||
assert!(zero.matte[40 * 100 + 46] != 0 && zero.matte[15 * 100 + 15] == 0, "only the disk");
|
||
chain.show_object = 7;
|
||
assert_eq!(shown(&chain).area(), 0, "no such object");
|
||
chain.show_object = 0;
|
||
assert!(shown(&chain).matte[15 * 100 + 15] != 0, "all together");
|
||
|
||
// the objects are saved with the tracking and come back from the project file
|
||
let saved = tracks_of(&chain).expect("tracking to save");
|
||
assert_eq!(saved.objects.keys().copied().collect::<Vec<_>>(), vec![0, 1]);
|
||
assert_eq!(saved.objects.get(&1).map(|f| f.len()), Some(9));
|
||
assert_eq!(TrackCache::from_json(&saved.to_json()), Some(saved.clone()));
|
||
// a tracking saved before objects existed has none
|
||
let old = TrackCache { objects: Default::default(), ..saved.clone() };
|
||
assert!(!old.to_json().contains("objects"));
|
||
let rt = decode_object(&saved, 3, 1).expect("decoded");
|
||
assert!(rt.matte[15 * 100 + 15] != 0 && rt.matte[40 * 100 + 46] == 0);
|
||
}
|
||
|
||
/// Instances saved before the propagation controls got their own twirl-down (and motion
|
||
/// blur / decontamination their nested ones) load with their values in the new places.
|
||
#[test]
|
||
fn old_group_layout_is_migrated_on_load() {
|
||
use effectcraft_project::build::Ids;
|
||
let spec = crate::find(ID).unwrap();
|
||
let mut next = 1;
|
||
let mut ids = Ids(&mut next);
|
||
// The old layout: everything under Roto Brush Matte / Refine Edge Matte.
|
||
let mut g = ids.group(ID, "Roto Brush & Refine Edge");
|
||
g.kind = effectcraft_project::GroupKind::Effect { effect: ID.into() };
|
||
let mut rb = ids.group("rotoBrushMatte", "Roto Brush Matte");
|
||
rb.children.push(ids.prop("searchRadius", "Search Radius", Value::Scalar(33.0)).into());
|
||
rb.children.push(ids.prop("feather", "Feather", Value::Scalar(2.0)).into());
|
||
let mut re = ids.group("refineEdgeMatte", "Refine Edge Matte");
|
||
re.children.push(ids.prop("shutterAngle", "Shutter Angle", Value::Scalar(90.0)).into());
|
||
re.children.push(ids.prop("decontaminationAmount", "Decontamination Amount", Value::Scalar(40.0)).into());
|
||
g.children.push(rb.into());
|
||
g.children.push(re.into());
|
||
assert!(crate::migrate::upgrade_instance(spec, &mut g, &mut ids, [100.0, 80.0]));
|
||
let params = crate::flatten_params(&g, &mut |p| p.value.clone());
|
||
assert_eq!(f(¶ms, "rotoBrushPropagation/searchRadius"), 33.0);
|
||
assert_eq!(f(¶ms, "rotoBrushMatte/feather"), 2.0);
|
||
assert_eq!(f(¶ms, "refineEdgeMatte/motionBlur/shutterAngle"), 90.0);
|
||
assert_eq!(f(¶ms, "refineEdgeMatte/decontamination/decontaminationAmount"), 40.0);
|
||
assert!(g.sub("rotoBrushMatte").unwrap().get("searchRadius").is_none());
|
||
assert_eq!(g.sub("rotoBrushPropagation").unwrap().name, "Roto Brush Propagation");
|
||
// Parameters the old save lacked get their defaults.
|
||
assert_eq!(f(¶ms, "rotoBrushPropagation/motionDamping"), 20.0);
|
||
}
|
||
}
|