#3536: one explicit analysis signature for Warp Stabilizer, 3D Camera Tracker and Roto Brush

analysis::frames_key writes what a layer's frames are made of field by
field: every struct and enum it writes is destructured or matched in full
and each variant is a fixed word, so a new field doesn't compile until it
is decided and a rename doesn't change keys. Names, labels, UI hints and
markers are left out. Roto Brush's Input Key writes masks and the effects
above it the same way, instead of their Debug text.

Keys saved by 0.22-0.24 are recognised by the old code, moved unchanged
to frames_key::legacy, and kept rather than rewritten (frozen mattes hash
them).
This commit is contained in:
arnav-hiwarkar committed 2026-11-12 14:08:57 +05:30
1 parent d361a6cb4e
commit 54d53fff76
9 files changed
+777 -176

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
pub mod camera_track;
pub(crate) mod frames_key;
pub mod mask_track;
pub mod models;
pub mod roto;
+3 -2
View File
@@ -26,9 +26,10 @@ use effectcraft_track::camtrack::linalg::{self, Similarity, V3};
use effectcraft_track::camtrack::{AnalyzeOpts, CameraSolve, CameraTracks, SolveSettings, Target, TrackAnalyzer};
use serde::Serialize;
use crate::analysis::frames_key::{self, signature};
use crate::offload::JobKind;
use crate::tracking::lock;
use crate::warp::{analysis_times, signature};
use crate::warp::analysis_times;
use crate::{Event, Session};
/// Live analysis progress (serde for agents / the control channel).
@@ -134,7 +135,7 @@ fn tracks_key_of(signature: String, g: &PropGroup) -> String {
/// Whether the tracker's saved tracks are made from the layer's current frames and settings.
fn tracks_match(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> bool {
crate::warp::key_matches(project, cid, l, &str_of(g, TRACKS_KEY), |s| tracks_key_of(s, g))
frames_key::key_matches(project, cid, l, &str_of(g, TRACKS_KEY), |s, _| tracks_key_of(s, g))
}
/// Parameters of an instance from static values (the tracker's settings are not animated).
+547
View File
@@ -0,0 +1,547 @@
//! What an analysis of a layer is made from, as one key shared by the Warp Stabilizer, the 3D
//! Camera Tracker and Roto Brush / Object Matte.
//!
//! The key hashes a text this module writes field by field. Every struct and enum it writes is
//! destructured or matched in full, and each variant is written as a fixed word. A new field or
//! variant doesn't compile until it is decided here: written if it changes the layer's frames,
//! left out (with a reason) if it doesn't. Renaming a field or variant doesn't change keys. The
//! text leaves out observations (whether footage is available) and data that never changes pixels
//! (names, labels, UI hints, markers), so they never drop saved analyses (#3534, #3536).
//!
//! Keys saved by 0.22–0.24 hashed derived `Debug` text instead. [`legacy`] still recognises
//! them, and a matching saved key is kept rather than rewritten: frozen Roto Brush mattes and
//! segmentations hash the saved key itself.
use std::fmt::Write as _;
use effectcraft_color::ColorSpace;
use effectcraft_keyframe::{Ease, FeatherPoint, Gradient, Interp, Keyframe, ShapePath, Value};
use effectcraft_project::{
AlphaMode, CineonSettings, CineonUnits, Expression, FeatherFalloff, FieldOrder, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerSource,
LightKind, MaskMode, MaskMotionBlur, Node, PrimitiveKind, Project, PropGroup, Property, Solid, SourceLayer, Uid,
};
use effectcraft_time::{FrameRate, Tick};
pub(crate) mod legacy;
/// The version of the text, its first word: a later change to what it writes bumps it.
const VERSION: &str = "frames/1";
/// Property groups nest a few levels (effect ▸ group ▸ property); deeper trees are cut here.
const MAX_DEPTH: usize = 32;
fn fnv(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
fn hex(s: &str) -> String {
format!("{:016x}", fnv(s))
}
/// Which signature a key is made from: today's, or one an earlier release saved.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Era {
Current,
Legacy,
}
/// The signature of `layer`'s frames: its source (and the source item), In/Out, start time,
/// stretch, Time Remap and the comp frame rate. Warp Stabilizer and 3D Camera Tracker keys are
/// made from it.
pub(crate) fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String {
hex(&signature_text(p, comp, layer))
}
/// The text [`signature`] hashes.
pub(crate) fn signature_text(p: &Project, comp: ItemId, layer: &Layer) -> String {
let mut t = Text::new();
t.word(VERSION);
t.layer_frames(p, comp, layer);
t.0
}
/// A Roto Brush's (or Object Matte's) Input Key: the layer's frames, its masks and the effects
/// above the instance `uid`.
pub(crate) fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
input_key_of(signature(p, comp, layer), layer, uid)
}
/// An Input Key from a [`signature`].
pub(crate) fn input_key_of(signature: String, layer: &Layer, uid: Uid) -> String {
let mut t = Text::new();
t.word(VERSION);
t.word(&signature);
t.inputs_above(layer, uid);
hex(&t.0)
}
/// Whether `saved`, a key saved with an analysis of `layer`, is for the layer's current frames.
/// `key` makes such a key from a signature of the given era. A key an earlier release saved for
/// the same frames matches too: keep it rather than rewrite it (analysing writes today's).
pub(crate) fn key_matches(p: &Project, comp: ItemId, layer: &Layer, saved: &str, key: impl Fn(String, Era) -> String) -> bool {
key(signature(p, comp, layer), Era::Current) == saved || legacy::signatures(p, comp, layer).into_iter().any(|s| key(s, Era::Legacy) == saved)
}
/// The text a key is hashed from. Fields are `name=value;`; strings are quoted and escaped, so
/// no value can end its field early.
struct Text(String);
impl Text {
fn new() -> Text {
Text(String::new())
}
fn word(&mut self, w: &str) {
self.0.push_str(w);
self.0.push(';');
}
fn field(&mut self, name: &str, value: impl std::fmt::Display) {
let _ = write!(self.0, "{name}={value};");
}
fn str(&mut self, name: &str, s: &str) {
let _ = write!(self.0, "{name}={s:?};");
}
fn num(&mut self, name: &str, v: f64) {
let _ = write!(self.0, "{name}={v:?};");
}
fn nums(&mut self, name: &str, v: &[f64]) {
let _ = write!(self.0, "{name}={v:?};");
}
fn floats(&mut self, name: &str, v: &[f32]) {
let _ = write!(self.0, "{name}={v:?};");
}
fn opt<T>(&mut self, name: &str, v: Option<&T>, write: impl FnOnce(&mut Text, &T)) {
match v {
Some(v) => {
let _ = write!(self.0, "{name}{{");
write(self, v);
self.0.push_str("};");
}
None => self.word(&format!("{name}=none")),
}
}
fn tick(&mut self, name: &str, t: Tick) {
self.field(name, t.0);
}
fn rate(&mut self, name: &str, r: FrameRate) {
let FrameRate { num, den } = r;
let _ = write!(self.0, "{name}={num}/{den};");
}
/// What a layer's frames are made of, before its masks and effects.
fn layer_frames(&mut self, p: &Project, comp: ItemId, layer: &Layer) {
self.layer_source(&layer.source);
if let Some(item) = layer.source.item() {
match p.item(item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => self.footage(f),
Some(ItemKind::Solid(s)) => self.solid(s),
// A precomp's own contents aren't in the signature yet (#3535).
Some(ItemKind::Comp(c)) => {
self.word("comp");
self.field("w", c.width);
self.field("h", c.height);
self.rate("rate", c.frame_rate);
self.tick("duration", c.duration);
}
Some(ItemKind::Folder) | None => self.word("item=none"),
}
}
self.tick("in", layer.in_point);
self.tick("out", layer.out_point);
self.tick("start", layer.start_time);
self.num("stretch", layer.stretch);
self.opt("timeRemap", layer.props.get("timeRemap"), |t, pr| t.property(pr));
self.opt("compRate", p.comp(comp).map(|c| &c.frame_rate), |t, r| t.rate("rate", *r));
}
fn layer_source(&mut self, s: &LayerSource) {
match s {
LayerSource::Footage { item } => self.field("footage", item.0),
LayerSource::Comp { item } => self.field("comp", item.0),
LayerSource::Solid { item } => self.field("solid", item.0),
LayerSource::Model { item } => self.field("model", item.0),
LayerSource::Text => self.word("text"),
LayerSource::Shape => self.word("shape"),
LayerSource::Null => self.word("null"),
LayerSource::Camera => self.word("camera"),
LayerSource::Light { kind } => self.field(
"light",
match kind {
LightKind::Parallel => "parallel",
LightKind::Spot => "spot",
LightKind::Point => "point",
LightKind::Ambient => "ambient",
LightKind::Environment => "environment",
},
),
LayerSource::Primitive { kind } => self.field(
"primitive",
match kind {
PrimitiveKind::Cube => "cube",
PrimitiveKind::Sphere => "sphere",
PrimitiveKind::Plane => "plane",
PrimitiveKind::Torus => "torus",
PrimitiveKind::Cone => "cone",
PrimitiveKind::Cylinder => "cylinder",
},
),
}
}
fn solid(&mut self, s: &Solid) {
let Solid { color, width, height, pixel_aspect } = s;
self.word("solid");
self.floats("color", color);
self.field("w", width);
self.field("h", height);
self.num("par", *pixel_aspect);
}
fn footage(&mut self, f: &Footage) {
let Footage {
path,
kind,
width,
height,
pixel_aspect,
frame_rate,
native_rate,
duration,
has_video,
has_audio,
alpha,
premul_color,
loop_count,
codec,
missing,
sequence,
color_profile,
layer,
fields,
invert_alpha,
linear_light,
preserve_rgb,
cineon,
data,
page,
alphabetical,
start_timecode,
file_timecode,
// Markers annotate the footage; they never change its frames.
markers: _,
placed_page,
} = f;
self.word("footage");
self.str("path", path);
self.field(
"kind",
match kind {
FootageKind::Video => "video",
FootageKind::Still => "still",
FootageKind::Sequence => "sequence",
FootageKind::Audio => "audio",
FootageKind::Model => "model",
FootageKind::Data => "data",
},
);
self.field("w", width);
self.field("h", height);
self.num("par", *pixel_aspect);
self.rate("rate", *frame_rate);
self.opt("nativeRate", native_rate.as_ref(), |t, r| t.rate("rate", *r));
self.tick("duration", *duration);
self.field("video", has_video);
self.field("audio", has_audio);
self.field(
"alpha",
match alpha {
AlphaMode::Straight => "straight",
AlphaMode::Premultiplied => "premultiplied",
AlphaMode::Ignore => "ignore",
},
);
self.floats("matte", premul_color);
self.field("loop", loop_count);
self.str("codec", codec);
self.field("missing", missing);
self.field("frames", sequence.len());
for s in sequence {
self.str("file", s);
}
self.opt("profile", color_profile.as_ref(), |t, c| t.word(color_space(*c)));
self.opt("layer", layer.as_ref(), |t, l| t.source_layer(l));
self.field(
"fields",
match fields {
FieldOrder::Off => "off",
FieldOrder::UpperFirst => "upper",
FieldOrder::LowerFirst => "lower",
},
);
self.field("invertAlpha", invert_alpha);
self.field("linearLight", linear_light);
self.field("preserveRgb", preserve_rgb);
self.cineon(cineon);
self.opt("data", data.as_ref(), |t, d| t.str("text", d));
self.field("page", page);
self.field("alphabetical", alphabetical);
self.opt("startTimecode", start_timecode.as_ref(), |t, v| t.field("frame", v));
self.opt("fileTimecode", file_timecode.as_ref(), |t, v| t.field("frame", v));
self.field("placedPage", placed_page);
}
fn source_layer(&mut self, l: &SourceLayer) {
let SourceLayer { index, name, layer_size, embedded, placed } = l;
self.field("index", index);
// The layer is found by its index; the name only labels it.
let _ = name;
self.field("layerSize", layer_size);
self.opt("embedded", embedded.as_ref(), |t, e| t.str("id", e));
self.field("placed", placed);
}
fn cineon(&mut self, c: &CineonSettings) {
let CineonSettings { log_conversion, converted_black, converted_white, black, white, gamma, rolloff, units } = c;
self.word("cineon");
self.field("log", log_conversion);
self.floats("points", &[*converted_black, *converted_white, *black, *white, *gamma, *rolloff]);
self.field(
"units",
match units {
CineonUnits::TenBit => "10bit",
CineonUnits::EightBit => "8bit",
CineonUnits::Float => "float",
},
);
}
/// The layer's masks and the effects above the instance `uid`: what the effect's input is
/// made of besides the layer's frames.
fn inputs_above(&mut self, layer: &Layer, uid: Uid) {
if let Some(m) = layer.masks() {
self.word("masks");
self.group(m, 0);
}
if let Some(fx) = layer.effects() {
self.word("effects");
for g in fx.groups().take_while(|g| g.uid != uid) {
self.group(g, 0);
}
}
}
fn group(&mut self, g: &PropGroup, depth: usize) {
// The uid, name and colour label identify and label the group; they never change pixels.
let PropGroup { uid: _, match_id, name: _, kind, enabled, label: _, children } = g;
if depth >= MAX_DEPTH {
self.word("tooDeep");
return;
}
self.str("group", match_id);
self.group_kind(kind);
self.field("enabled", enabled);
for c in children {
match c {
Node::Prop(p) => self.property(p),
Node::Group(g) => self.group(g, depth + 1),
}
}
self.word("end");
}
fn group_kind(&mut self, k: &GroupKind) {
match k {
GroupKind::Plain => self.word("plain"),
GroupKind::Indexed => self.word("indexed"),
// A mask's colour and lock only show it in the UI.
GroupKind::Mask { mode, inverted, color: _, locked: _, motion_blur, feather_falloff, roto_bezier } => {
self.field(
"mask",
match mode {
MaskMode::None => "none",
MaskMode::Add => "add",
MaskMode::Subtract => "subtract",
MaskMode::Intersect => "intersect",
MaskMode::Lighten => "lighten",
MaskMode::Darken => "darken",
MaskMode::Difference => "difference",
},
);
self.field("inverted", inverted);
self.field(
"motionBlur",
match motion_blur {
MaskMotionBlur::SameAsLayer => "layer",
MaskMotionBlur::On => "on",
MaskMotionBlur::Off => "off",
},
);
self.field(
"falloff",
match feather_falloff {
FeatherFalloff::Smooth => "smooth",
FeatherFalloff::Linear => "linear",
},
);
self.field("rotoBezier", roto_bezier);
}
GroupKind::Effect { effect } => self.str("effect", effect),
// A motion tracker's settings say how to track, not how to draw the layer.
GroupKind::Tracker { settings: _ } => self.word("tracker"),
GroupKind::Essential { overridden } => {
self.word("essential");
for u in overridden {
self.field("overridden", u);
}
}
}
}
fn property(&mut self, pr: &Property) {
// The uid, name and UI hints identify and show the property; the 2D/3D-only and
// hold-only flags say where and how it can be edited, not what it evaluates to.
let Property {
uid: _,
match_id,
name: _,
value,
keys,
expr,
ui: _,
spatial,
hold_only: _,
three_d_only: _,
two_d_only: _,
shown_dims: _,
static_only: _,
} = pr;
self.str("prop", match_id);
self.value(value);
self.field("spatial", spatial);
for k in keys {
self.keyframe(k);
}
self.opt("expr", expr.as_ref(), |t, e| {
let Expression { text, enabled } = e;
t.str("text", text);
t.field("enabled", enabled);
});
}
fn keyframe(&mut self, k: &Keyframe) {
// A key's colour label only shows it in the UI.
let Keyframe {
time,
value,
in_interp,
out_interp,
in_ease,
out_ease,
continuous,
auto_bezier,
spatial_in,
spatial_out,
spatial_auto,
spatial_continuous,
roving,
label: _,
muted,
} = k;
self.tick("key", *time);
self.value(value);
self.field("interp", format_args!("{}/{}", interp(*in_interp), interp(*out_interp)));
for (side, eases) in [("inEase", in_ease), ("outEase", out_ease)] {
for e in eases {
let Ease { speed, influence } = e;
self.nums(side, &[*speed, *influence]);
}
}
self.field("continuous", continuous);
self.field("autoBezier", auto_bezier);
self.nums("spatialIn", spatial_in);
self.nums("spatialOut", spatial_out);
self.field("spatialAuto", spatial_auto);
self.field("spatialContinuous", spatial_continuous);
self.field("roving", roving);
self.field("muted", muted);
}
fn value(&mut self, v: &Value) {
match v {
Value::Scalar(x) => self.num("scalar", *x),
Value::Vec2(x) => self.nums("vec2", x),
Value::Vec3(x) => self.nums("vec3", x),
Value::Color(x) => self.nums("color", x),
Value::Bool(b) => self.field("bool", b),
Value::Enum(i) => self.field("enum", i),
Value::Path(p) => self.shape_path(p),
// A text document has dozens of fields and no place in what a key covers today (a
// text layer's Source Text is not under its masks or effects): its project-file form.
Value::Text(d) => self.str("text", &serde_json::to_string(d).unwrap_or_default()),
Value::Layer(l) => self.opt("layer", l.as_ref(), |t, id| t.field("id", id)),
Value::Gradient(g) => {
let Gradient { colors, opacities } = g;
self.word("gradient");
for (at, c) in colors {
self.num("stop", *at);
self.floats("rgba", c);
}
for (at, o) in opacities {
self.num("stop", *at);
self.floats("opacity", &[*o]);
}
}
Value::Str(s) => self.str("str", s),
}
}
fn shape_path(&mut self, p: &ShapePath) {
let ShapePath { vertices, in_tangents, out_tangents, closed, feather } = p;
self.word("path");
for (name, points) in [("v", vertices), ("in", in_tangents), ("out", out_tangents)] {
for pt in points {
self.nums(name, pt);
}
}
self.field("closed", closed);
for f in feather {
let FeatherPoint { segment, t, radius, tension } = f;
self.field("featherSegment", segment);
self.nums("feather", &[*t, *radius, *tension]);
}
}
}
fn interp(i: Interp) -> &'static str {
match i {
Interp::Linear => "linear",
Interp::Bezier => "bezier",
Interp::Hold => "hold",
}
}
fn color_space(c: ColorSpace) -> &'static str {
match c {
ColorSpace::Srgb => "srgb",
ColorSpace::Rec709 => "rec709",
ColorSpace::Rec2020 => "rec2020",
ColorSpace::DisplayP3 => "p3",
ColorSpace::AcesCg => "acescg",
ColorSpace::Aces2065 => "aces2065",
ColorSpace::Rec2100Pq => "rec2100pq",
ColorSpace::Rec2100Hlg => "rec2100hlg",
}
}
@@ -0,0 +1,144 @@
//! Signatures as 0.22–0.24 wrote them, to recognise the keys those releases saved. Nothing new is
//! keyed with them. They hash derived `Debug` text, so `tests::analysis_signature` pins keys
//! those releases saved: if a type change alters this text, write that part out here.
use effectcraft_project::{Footage, ItemId, ItemKind, Layer, LayerSource, Project, Uid};
use super::fnv;
/// The signatures a saved key of `layer` may have been made from. Before `Footage::placed_page`
/// was added in 0.23, the footage text ended without that field.
pub(crate) fn signatures(p: &Project, comp: ItemId, layer: &Layer) -> Vec<String> {
let src = match &layer.source {
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => {
let mut out = Vec::with_capacity(2);
out.push(signature_with_source(p, comp, layer, &footage_text(f, f.missing, true)));
if matches!(layer.source, LayerSource::Footage { .. }) && !f.placed_page {
out.push(signature_with_source(p, comp, layer, &footage_text(f, f.missing, false)));
}
return out;
}
Some(ItemKind::Solid(s)) => format!("{s:?}"),
Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration),
_ => String::new(),
},
_ => String::new(),
};
vec![signature_with_source(p, comp, layer, &src)]
}
/// A Roto Brush Input Key as 0.22–0.24 made it from a [`signatures`] entry.
pub(crate) fn input_key_of(mut s: String, layer: &Layer, uid: Uid) -> String {
if let Some(m) = layer.masks() {
s.push_str(&format!("{m:?}"));
}
if let Some(fx) = layer.effects() {
for g in fx.groups() {
if g.uid == uid {
break;
}
s.push_str(&format!("{g:?}"));
}
}
format!("{:016x}", fnv(&s))
}
/// A footage item as 0.23–0.24 wrote it into a signature (without `placed_page` before 0.23),
/// with `missing` in place of the item's.
pub(crate) fn footage_text(f: &Footage, missing: bool, placed_page: bool) -> String {
format!("{:?}", FootageText { f, missing, placed_page })
}
struct FootageText<'a> {
f: &'a Footage,
missing: bool,
placed_page: bool,
}
impl std::fmt::Debug for FootageText<'_> {
fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Every field is named so that a new one doesn't compile until it is decided here: a new
// field was never in these keys, so it stays out.
let Footage {
path,
kind,
width,
height,
pixel_aspect,
frame_rate,
native_rate,
duration,
has_video,
has_audio,
alpha,
premul_color,
loop_count,
codec,
missing: _,
sequence,
color_profile,
layer,
fields,
invert_alpha,
linear_light,
preserve_rgb,
cineon,
data,
page,
alphabetical,
start_timecode,
file_timecode,
markers,
placed_page,
} = self.f;
let mut d = out.debug_struct("Footage");
d.field("path", path)
.field("kind", kind)
.field("width", width)
.field("height", height)
.field("pixel_aspect", pixel_aspect)
.field("frame_rate", frame_rate)
.field("native_rate", native_rate)
.field("duration", duration)
.field("has_video", has_video)
.field("has_audio", has_audio)
.field("alpha", alpha)
.field("premul_color", premul_color)
.field("loop_count", loop_count)
.field("codec", codec)
.field("missing", &self.missing)
.field("sequence", sequence)
.field("color_profile", color_profile)
.field("layer", layer)
.field("fields", fields)
.field("invert_alpha", invert_alpha)
.field("linear_light", linear_light)
.field("preserve_rgb", preserve_rgb)
.field("cineon", cineon)
.field("data", data)
.field("page", page)
.field("alphabetical", alphabetical)
.field("start_timecode", start_timecode)
.field("file_timecode", file_timecode)
.field("markers", markers);
if self.placed_page {
d.field("placed_page", placed_page);
}
d.finish()
}
}
fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String {
let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default();
let s = format!(
"{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}",
layer.source,
layer.in_point,
layer.out_point,
layer.start_time,
layer.stretch,
layer.props.get("timeRemap")
);
format!("{:016x}", fnv(&s))
}
+7 -26
View File
@@ -31,6 +31,7 @@ use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpt
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use crate::analysis::frames_key::{self, Era};
use crate::tracking::lock;
use crate::{Event, Session};
@@ -207,34 +208,10 @@ fn set_str(g: &mut PropGroup, m: &str, v: String) {
}
}
fn fnv(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
/// What the effect's input frames are made of: the layer's source and timing, its masks and the
/// effects above the Roto Brush.
pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid)
}
fn input_key_with_signature(mut s: String, layer: &Layer, uid: Uid) -> String {
if let Some(m) = layer.masks() {
s.push_str(&format!("{m:?}"));
}
if let Some(fx) = layer.effects() {
for g in fx.groups() {
if g.uid == uid {
break;
}
s.push_str(&format!("{g:?}"));
}
}
format!("{:016x}", fnv(&s))
frames_key::input_key(p, comp, layer, uid)
}
/// After an edit: refresh Input Keys (Roto Brush and Object Matte); frozen mattes made from
@@ -252,7 +229,11 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI
let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid));
let saved_key = str_of(g, INPUT_KEY);
// Keep a matching old key: the frozen cache itself also hashes that key.
if !crate::warp::key_matches(after, *cid, l, &saved_key, |s| input_key_with_signature(s, l, g.uid)) {
let key_of = |s, era| match era {
Era::Current => frames_key::input_key_of(s, l, g.uid),
Era::Legacy => frames_key::legacy::input_key_of(s, l, g.uid),
};
if !frames_key::key_matches(after, *cid, l, &saved_key, key_of) {
set.push((*cid, l.id, g.uid, key));
todo.push((*cid, l.id, g.uid));
} else if old.is_none_or(|o| guard_of(o) != guard_of(g)) {
+3 -144
View File
@@ -17,13 +17,14 @@ use std::sync::{Arc, Mutex};
use effectcraft_effects::warp_stab::{self, ANALYSIS, ANALYSIS_KEY};
use effectcraft_keyframe::Value;
use effectcraft_project::{Footage, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid};
use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
use effectcraft_time::Tick;
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis};
use serde::Serialize;
use crate::analysis::frames_key::{self, signature};
use crate::tracking::lock;
use crate::{Event, Session};
@@ -98,148 +99,6 @@ pub(crate) struct WarpWork {
pub times: Vec<Tick>,
}
fn fnv(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
/// What an analysis of `layer` depends on (its frames): source (and the footage item's
/// interpretation), In/Out, start time, stretch, Time Remap and the comp frame rate.
pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String {
let src = match &layer.source {
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => footage_text(f, true),
Some(ItemKind::Solid(s)) => format!("{s:?}"),
Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration),
_ => String::new(),
},
_ => String::new(),
};
signature_with_source(p, comp, layer, &src)
}
/// Whether `saved`, a key an analysis of `layer` was saved with, is for the layer's current frames.
/// `key` makes such a key from a signature. A key made from the signature an earlier release wrote
/// for the same frames matches too: keep it rather than rewrite it (analysing writes today's).
pub(crate) fn key_matches(p: &Project, comp: ItemId, layer: &Layer, saved: &str, key: impl Fn(String) -> String) -> bool {
key(signature(p, comp, layer)) == saved || signature_before_placed_page(p, comp, layer).is_some_and(|s| key(s) == saved)
}
/// Before `Footage::placed_page` was added in 0.23, the persisted signature hashed the
/// same footage text without that last field. Only ordinary footage can match it.
fn signature_before_placed_page(p: &Project, comp: ItemId, layer: &Layer) -> Option<String> {
let LayerSource::Footage { item } = &layer.source else { return None };
let ItemKind::Footage(footage) = &p.item(*item)?.kind else { return None };
if footage.placed_page {
return None;
}
Some(signature_with_source(p, comp, layer, &footage_text(footage, false)))
}
/// A footage item in a signature: its fields as `Debug` wrote them in 0.23, the text saved
/// analysis keys were hashed from (without `placed_page` before 0.23). It is written out here so
/// that a new `Footage` field doesn't change every signature and drop every saved analysis.
pub(crate) fn footage_text(f: &Footage, placed_page: bool) -> String {
format!("{:?}", FootageText { f, placed_page })
}
struct FootageText<'a> {
f: &'a Footage,
placed_page: bool,
}
impl std::fmt::Debug for FootageText<'_> {
fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Every field is named, so a new one doesn't compile until it is decided here. A new field
// that changes the frames goes last, written only when it isn't its default, so keys saved
// before it still match. A new field that doesn't change the frames stays out.
let Footage {
path,
kind,
width,
height,
pixel_aspect,
frame_rate,
native_rate,
duration,
has_video,
has_audio,
alpha,
premul_color,
loop_count,
codec,
missing,
sequence,
color_profile,
layer,
fields,
invert_alpha,
linear_light,
preserve_rgb,
cineon,
data,
page,
alphabetical,
start_timecode,
file_timecode,
markers,
placed_page,
} = self.f;
let mut d = out.debug_struct("Footage");
d.field("path", path)
.field("kind", kind)
.field("width", width)
.field("height", height)
.field("pixel_aspect", pixel_aspect)
.field("frame_rate", frame_rate)
.field("native_rate", native_rate)
.field("duration", duration)
.field("has_video", has_video)
.field("has_audio", has_audio)
.field("alpha", alpha)
.field("premul_color", premul_color)
.field("loop_count", loop_count)
.field("codec", codec)
.field("missing", missing)
.field("sequence", sequence)
.field("color_profile", color_profile)
.field("layer", layer)
.field("fields", fields)
.field("invert_alpha", invert_alpha)
.field("linear_light", linear_light)
.field("preserve_rgb", preserve_rgb)
.field("cineon", cineon)
.field("data", data)
.field("page", page)
.field("alphabetical", alphabetical)
.field("start_timecode", start_timecode)
.field("file_timecode", file_timecode)
.field("markers", markers);
if self.placed_page {
d.field("placed_page", placed_page);
}
d.finish()
}
}
fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String {
let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default();
let s = format!(
"{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}",
layer.source,
layer.in_point,
layer.out_point,
layer.start_time,
layer.stretch,
layer.props.get("timeRemap")
);
format!("{:016x}", fnv(&s))
}
fn is_warp(g: &PropGroup) -> bool {
matches!(&g.kind, GroupKind::Effect { effect } if effect == warp_stab::ID)
}
@@ -266,7 +125,7 @@ pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId,
let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id));
for g in fx.groups().filter(|g| is_warp(g)) {
let analysed = !str_of(g, ANALYSIS).is_empty();
if analysed && !key_matches(after, *cid, l, &str_of(g, ANALYSIS_KEY), |s| s) {
if analysed && !frames_key::key_matches(after, *cid, l, &str_of(g, ANALYSIS_KEY), |s, _| s) {
clear.push((*cid, l.id, g.uid));
todo.push((*cid, l.id, g.uid));
} else if !analysed && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() {
+1 -1
View File
@@ -1,6 +1,7 @@
mod add_item_options;
mod aep_export;
mod aep_import;
mod analysis_signature;
mod anim_presets;
mod anim_tools;
mod aux_footage;
@@ -27,7 +28,6 @@ mod key_metadata_order;
mod key_time;
mod keyframe_text;
mod keylight;
mod analysis_signature;
mod legacy_analysis_keys;
mod limits;
mod lottie;
@@ -92,3 +92,71 @@ fn analyses_saved_by_0_24_survive_edits_that_keep_their_frames() {
s.execute_checked("edit.undo", json!({})).unwrap();
assert_eq!(saved(&s), before);
}
/// The text keys are made from today, written field by field (#3536). It changes only when this
/// test is changed with it: a new field of a type it writes doesn't compile until it is decided
/// in `analysis::frames_key`, and renaming a field or variant doesn't change it.
#[test]
fn signature_text_is_pinned() {
let s = open(ANALYSED_0_24);
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
let key = r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=video;w=160;h=120;par=1.0;rate=12/1;nativeRate=none;duration=254016000000;video=true;audio=false;alpha=ignore;matte=[0.0, 0.0, 0.0];loop=1;codec="H.264 (High)";missing=false;frames=0;profile{rec709;};layer=none;fields=off;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=false;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=10bit;data=none;page=0;alphabetical=false;startTimecode=none;fileTimecode=none;placedPage=false;in=0;out=254016000000;start=0;stretch=100.0;timeRemap{prop="timeRemap";scalar=0.0;spatial=false;key=0;scalar=0.2;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;key=254016000000;scalar=1.0;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;expr=none;};compRate{rate=12/1;};"#;
assert_eq!(crate::analysis::frames_key::signature_text(&s.project, COMP, layer), key);
}
/// Keys made today (after analysing again) match through edits, undo and save/reopen too, and
/// renaming or relabelling never drops them.
#[test]
fn todays_keys_survive_edits_that_keep_their_frames() {
let mut s = open(ANALYSED_0_24);
// Analysing again writes today's keys.
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
let signature = crate::analysis::frames_key::signature(&s.project, COMP, layer);
let input = crate::roto::input_key(&s.project, COMP, layer, ROTO);
assert_ne!(signature, SIGNATURE_0_24);
s.edit("analyse", None, |p, _| {
let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap();
for (uid, id, key) in [(WARP, ANALYSIS_KEY, &signature), (TRACKER, TRACKS_KEY, &signature), (ROTO, INPUT_KEY, &input)] {
l.props.find_group_mut(uid).unwrap().get_mut(id).unwrap().value = Value::Str(key.clone());
}
Ok(())
})
.unwrap();
// The Roto Brush's key covers the effects above it: the Warp Stabilizer's new key changed it.
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
let input = crate::roto::input_key(&s.project, COMP, layer, ROTO);
s.edit("roto key", None, |p, _| {
let g = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(ROTO).unwrap();
g.get_mut(INPUT_KEY).unwrap().value = Value::Str(input.clone());
g.get_mut(FROZEN).unwrap().value = Value::Str("saved frozen".into());
Ok(())
})
.unwrap();
let before = saved(&s);
assert_eq!(before[0].1, signature);
assert_eq!(before[2], ("saved frozen".into(), input));
add_to_render_queue(&mut s);
assert_eq!(saved(&s), before);
s.execute_checked("edit.undo", json!({})).unwrap();
assert_eq!(saved(&s), before);
let mut s = open(&s.project.to_json());
add_to_render_queue(&mut s);
assert_eq!(saved(&s), before);
// Names and labels only show things.
s.execute_checked("layer.rename", json!({"layer": CLIP.0, "name": "Renamed"})).unwrap();
s.edit("rename", None, |p, _| {
let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap();
l.props.find_group_mut(82).unwrap().name = "Soft".into();
if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) {
f.markers.push(effectcraft_project::Marker::default());
}
Ok(())
})
.unwrap();
assert_eq!(saved(&s), before);
// A real change still clears them.
s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap();
assert!(saved(&s).iter().all(|(data, _)| data.is_empty()));
}
@@ -50,7 +50,7 @@ fn analyses_saved_by_0_22_survive_edits_that_keep_their_frames() {
for (len, _, key) in &before {
assert!(*len > 0);
// The key predates Footage::placed_page, so it isn't today's signature.
assert_ne!(key, &crate::warp::signature(&s.project, COMP, layer));
assert_ne!(key, &crate::analysis::frames_key::signature(&s.project, COMP, layer));
}
// Adding to the render queue edits the project, as `effectcraft-cli render` does.
@@ -122,6 +122,6 @@ fn footage_signature_text_is_pinned() {
..Footage::default()
};
let fields = r#"path: "clip.mp4", kind: Video, width: 160, height: 120, pixel_aspect: 1.0, frame_rate: FrameRate { num: 30, den: 1 }, native_rate: None, duration: Tick(0), has_video: false, has_audio: false, alpha: Straight, premul_color: [0.0, 0.0, 0.0], loop_count: 1, codec: "", missing: false, sequence: [], color_profile: None, layer: None, fields: Off, invert_alpha: true, linear_light: false, preserve_rgb: false, cineon: CineonSettings { log_conversion: false, converted_black: 0.0, converted_white: 1023.0, black: 95.0, white: 685.0, gamma: 1.7, rolloff: 20.0, units: TenBit }, data: None, page: 2, alphabetical: false, start_timecode: None, file_timecode: None, markers: []"#;
assert_eq!(crate::warp::footage_text(&f, true), format!("Footage {{ {fields}, placed_page: true }}"));
assert_eq!(crate::warp::footage_text(&f, false), format!("Footage {{ {fields} }}"));
assert_eq!(crate::analysis::frames_key::legacy::footage_text(&f, false, true), format!("Footage {{ {fields}, placed_page: true }}"));
assert_eq!(crate::analysis::frames_key::legacy::footage_text(&f, false, false), format!("Footage {{ {fields} }}"));
}