Merge pull request #3550 from arnav-hiwarkar/fix/3534-3536-analysis-signatures

Warp Stabilizer, 3D Camera Tracker, Roto Brush: one explicit analysis key; offline footage keeps analyses (#3534, #3536)
This commit is contained in:
ash authored and GitHub committed 2026-11-12 16:11:59 +05:30
commit 44aa7302f8
12 files changed
+1042 -181

No files matched your search

+1
View File
@@ -133,6 +133,7 @@ configuration from these Apache-2.0 projects:
| Asset | Author | Source | Licence |
|---|---|---|---|
| `crates/engine/src/tests/fixtures/analysed-0.24.ecproj` | arnav-hiwarkar | Original work, synthetic project keyed by EffectCraft 0.24.1 | MIT OR Apache-2.0 |
| `crates/engine/src/tests/fixtures/object-matte-0.22.ecproj` | lisn0 | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 |
| `crates/engine/src/tests/fixtures/tracked-0.22.ecproj` | arnav-hiwarkar | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 |
+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).
+545
View File
@@ -0,0 +1,545 @@
//! 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. The source item (footage and its
//! settings, solid), the Time Remap property and the masks and effect groups (their properties,
//! keyframes and values) are 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. Of the layer and of a precomp, only the fields named in `layer_frames` are
//! read (a precomp's contents are #3535). The
//! text leaves out observations (whether footage is available) and data that never changes pixels
//! (colour 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,
// Whether the file is there is an observation (`footage.check`), not an edit: an
// offline file is the same footage, and its analyses stay valid (#3534).
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("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) {
// Expressions find effects and groups by name (`effect("Blur")`, `propertyGroup(1).name`).
// The uid and colour label 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.str("name", name);
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) {
// Expressions read the name (`thisProperty.name`) and seed `random`/`wiggle` with the
// uid: the uid is written for a property with an expression. UI hints and 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.str("name", name);
if expr.is_some() {
self.field("uid", uid);
}
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,147 @@
//! 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. The footage's availability is
/// tried both ways: those releases hashed it, though it isn't part of the footage (#3534). 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(4);
for missing in [false, true] {
out.push(signature_with_source(p, comp, layer, &footage_text(f, missing, true)));
if matches!(layer.source, LayerSource::Footage { .. }) && !f.placed_page {
out.push(signature_with_source(p, comp, layer, &footage_text(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))
}
+9 -28
View File
@@ -8,8 +8,8 @@
//! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same
//! pass and stores the final mattes in the effect (one undo step). `sync`, run after every
//! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
//! mattes made from other frames.
//! of (the layer's source and timing, its masks and the effects above it: `analysis::frames_key`),
//! and drops frozen mattes made from other frames. Footage going offline keeps them.
//!
//! Object Matte instances run through the same jobs (`objectMatte.propagate` / `.freeze`): the
//! frames are [`om::job_frames`] around the reference frame, each segmented through
@@ -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)) {
+8 -148
View File
@@ -6,10 +6,11 @@
//! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the
//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
//! automatically too).
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap; the key is
//! `analysis::frames_key`'s, shared with the 3D Camera Tracker and Roto Brush). Edits that change
//! those clear the analysis (`invalidate`, run after every edit) and queue the effect for
//! re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
//! automatically too). Footage going offline is not such a change.
use crate::offload::{JobKind, rate};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -17,13 +18,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 +100,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 +126,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
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;
@@ -0,0 +1,315 @@
//! Warp Stabilizer, 3D Camera Tracker and Roto Brush keys saved by 0.24 keep matching their
//! layer's frames after edits that don't change them, and a real change still clears them.
use effectcraft_effects::camera_tracker::{TRACKS, TRACKS_KEY};
use effectcraft_effects::roto::{FROZEN, INPUT_KEY};
use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY};
use effectcraft_keyframe::Value;
use effectcraft_project::{ItemId, LayerId, Project};
use serde_json::json;
use crate::Session;
const COMP: ItemId = ItemId(2);
const CLIP: LayerId = LayerId(3);
const WARP: u64 = 26;
const TRACKER: u64 = 54;
const ROTO: u64 = 86;
/// Saved by 0.24.1 (before #3534/#3536): the clip of `tracked-0.22.ecproj` with Time Remap, a
/// mask, a Gaussian Blur and a Roto Brush, its analyses replaced by short stand-ins. The keys
/// are the ones that version computed for it.
const ANALYSED_0_24: &str = include_str!("fixtures/analysed-0.24.ecproj");
const SIGNATURE_0_24: &str = "03efb7c4b0f27b65";
const ROTO_INPUT_0_24: &str = "7b319ce93027617c";
fn open(json: &str) -> Session {
let mut s = Session::default();
s.replace_project(Project::from_json(json).unwrap(), None);
s.open_comp(COMP);
s
}
/// The (data, key) of the Warp Stabilizer, the 3D Camera Tracker and the Roto Brush.
fn saved(s: &Session) -> [(String, String); 3] {
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
let get = |uid: u64, data: &str, key: &str| {
let params = crate::camera_track::static_params(layer.props.find_group(uid).unwrap());
let text = |id: &str| match params.get(id) {
Some(Value::Str(s)) => s.clone(),
other => panic!("{id}: {other:?}"),
};
(text(data), text(key))
};
[get(WARP, ANALYSIS, ANALYSIS_KEY), get(TRACKER, TRACKS, TRACKS_KEY), get(ROTO, FROZEN, INPUT_KEY)]
}
fn add_to_render_queue(s: &mut Session) {
s.execute_checked("renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})).unwrap();
}
#[test]
fn analyses_saved_by_0_24_survive_edits_that_keep_their_frames() {
let mut s = open(ANALYSED_0_24);
let before = saved(&s);
assert_eq!(before[0], ("saved analysis".into(), SIGNATURE_0_24.into()));
assert_eq!(before[1], ("saved tracks".into(), SIGNATURE_0_24.into()));
assert_eq!(before[2], ("saved frozen".into(), ROTO_INPUT_0_24.into()));
add_to_render_queue(&mut s);
assert_eq!(saved(&s), before);
s.execute_checked("edit.undo", json!({})).unwrap();
assert_eq!(saved(&s), before);
// Saved and reopened, the keys still match.
let mut s = open(&s.project.to_json());
add_to_render_queue(&mut s);
assert_eq!(saved(&s), before);
// Other frames: all three are cleared (the Roto Brush gets a new key), and undo brings them back.
s.execute_checked("layer.timing", json!({"layer": CLIP.0, "out": 0.25})).unwrap();
let after = saved(&s);
assert_eq!(after[0], (String::new(), String::new()));
assert_eq!(after[1], (String::new(), String::new()));
assert_eq!(after[2].0, "");
assert_ne!(after[2].1, ROTO_INPUT_0_24);
s.execute_checked("edit.undo", json!({})).unwrap();
assert_eq!(saved(&s), before);
// Time Remap is part of the frames.
s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "timeRemap", "value": 0.3})).unwrap();
assert!(saved(&s).iter().all(|(data, _)| data.is_empty()));
s.execute_checked("edit.undo", json!({})).unwrap();
// The Roto Brush's input also has the layer's masks and the effects above it.
s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "effects/#3/blurriness", "value": 4})).unwrap();
let after = saved(&s);
assert_eq!(after[2].0, "");
assert_ne!(after[2].1, ROTO_INPUT_0_24);
s.execute_checked("edit.undo", json!({})).unwrap();
s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3})).unwrap();
assert_eq!(saved(&s)[2].0, "");
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)";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";name="Time Remap";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);
// Offline and back online (#3534).
for missing in [true, false] {
if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) {
f.missing = missing;
}
add_to_render_queue(&mut s);
assert_eq!(saved(&s), before);
}
// Colour labels and footage markers only show things.
s.edit("label", None, |p, _| {
let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap();
l.props.find_group_mut(82).unwrap().label = Some(effectcraft_color::Label::Yellow);
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);
// Expressions find effects by name: renaming the effect above the Roto Brush is a change for it.
s.edit("rename", None, |p, _| {
p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(82).unwrap().name = "Soft".into();
Ok(())
})
.unwrap();
assert_eq!(saved(&s)[..2], before[..2]);
assert_eq!(saved(&s)[2].0, "");
s.execute_checked("edit.undo", json!({})).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()));
}
/// Footage going offline (or coming back) is an observation, not an edit: the next edit keeps
/// every analysis (#3534).
#[test]
fn offline_footage_keeps_analyses() {
let mut s = open(ANALYSED_0_24);
let before = saved(&s);
let missing = |s: &Session| matches!(&s.project.item(ItemId(1)).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.missing);
// The fixture's footage path doesn't exist here: the check marks it missing.
let r = s.execute_checked("footage.check", json!({"wait": true})).unwrap();
assert_eq!(r["missing"], 1, "{r}");
assert!(missing(&s));
add_to_render_queue(&mut s);
assert_eq!(saved(&s), before);
// Saved and reopened while offline, then back online.
let mut s = open(&s.project.to_json());
assert!(missing(&s));
add_to_render_queue(&mut s);
assert_eq!(saved(&s), before);
if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) {
f.missing = false;
}
add_to_render_queue(&mut s);
assert_eq!(saved(&s), before);
// A real change while offline still clears them.
s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap();
assert!(saved(&s).iter().all(|(data, _)| data.is_empty()));
}
/// A key 0.24.1 saved while the footage was offline (it hashed the availability) matches too,
/// online or offline. 0.24.1 gave the Roto Brush this Input Key when the fixture's footage went
/// offline and an edit cleared the other two analyses.
#[test]
fn keys_saved_by_0_24_while_offline_still_match() {
const ROTO_INPUT_0_24_OFFLINE: &str = "6183fdd46b514259";
let mut s = open(ANALYSED_0_24);
s.edit("offline", None, |p, _| {
if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) {
f.missing = true;
}
let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap();
// What 0.24.1's edit cleared then.
use effectcraft_effects::camera_tracker::{AVERAGE_ERROR, METHOD_USED, SOLVE, SOLVE_KEY};
for (uid, id) in
[(WARP, ANALYSIS), (WARP, ANALYSIS_KEY), (TRACKER, SOLVE), (TRACKER, SOLVE_KEY), (TRACKER, METHOD_USED), (TRACKER, TRACKS), (TRACKER, TRACKS_KEY)]
{
l.props.find_group_mut(uid).unwrap().prop_mut(id).unwrap().value = Value::Str(String::new());
}
l.props.find_group_mut(TRACKER).unwrap().prop_mut(AVERAGE_ERROR).unwrap().value = Value::Scalar(0.0);
let g = l.props.find_group_mut(ROTO).unwrap();
g.get_mut(INPUT_KEY).unwrap().value = Value::Str(ROTO_INPUT_0_24_OFFLINE.into());
Ok(())
})
.unwrap();
let roto = |s: &Session| saved(s)[2].clone();
assert_eq!(roto(&s), ("saved frozen".into(), ROTO_INPUT_0_24_OFFLINE.into()));
for missing in [true, false] {
if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) {
f.missing = missing;
}
add_to_render_queue(&mut s);
assert_eq!(roto(&s), ("saved frozen".into(), ROTO_INPUT_0_24_OFFLINE.into()));
}
}
/// Expressions seed `random` and `wiggle` with the property's uid: a Time Remap with an expression
/// keys its uid (a duplicated layer gets new uids), one without doesn't.
#[test]
fn an_expression_keys_its_property_uid() {
let s = open(ANALYSED_0_24);
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap().clone();
let text = |l: &effectcraft_project::Layer| crate::analysis::frames_key::signature_text(&s.project, COMP, l);
let renumbered = |l: &effectcraft_project::Layer| {
let mut l = l.clone();
l.props.get_mut("timeRemap").unwrap().uid += 1000;
l
};
assert_eq!(text(&renumbered(&layer)), text(&layer));
let mut with_expr = layer.clone();
with_expr.props.get_mut("timeRemap").unwrap().expr =
Some(effectcraft_project::Expression { text: "seedRandom(0, true); random(0, 1)".into(), enabled: true });
assert_ne!(text(&renumbered(&with_expr)), text(&with_expr));
}
/// Non-default values of the types the key writes are written as their fixed words (#3536).
#[test]
fn nested_values_are_written_as_fixed_words() {
use effectcraft_project::{
AlphaMode, CineonUnits, FieldOrder, FootageKind, GroupKind, ItemKind, LayerSource, MaskMode, MaskMotionBlur, Solid, SourceLayer,
};
let mut s = open(ANALYSED_0_24);
let p = std::sync::Arc::make_mut(&mut s.project);
if let Some(ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) {
f.kind = FootageKind::Sequence;
f.sequence = vec!["a_0001.png".into(), "a_0002.png".into()];
f.alpha = AlphaMode::Premultiplied;
f.premul_color = [0.5, 0.25, 1.0];
f.fields = FieldOrder::UpperFirst;
f.cineon.units = CineonUnits::Float;
f.cineon.log_conversion = true;
f.color_profile = Some(effectcraft_color::ColorSpace::DisplayP3);
f.layer = Some(SourceLayer { index: 3, name: "Sky".into(), layer_size: true, embedded: Some("id-7".into()), placed: true });
f.native_rate = Some(effectcraft_time::FrameRate { num: 30000, den: 1001 });
f.start_timecode = Some(42);
f.data = Some("{\"k\":1}".into());
}
let layer = p.comp(COMP).unwrap().layer(CLIP).unwrap().clone();
let text = crate::analysis::frames_key::signature_text(p, COMP, &layer);
let footage = text.split(";in=").next().unwrap();
assert_eq!(
footage,
r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=sequence;w=160;h=120;par=1.0;rate=12/1;nativeRate{rate=30000/1001;};duration=254016000000;video=true;audio=false;alpha=premultiplied;matte=[0.5, 0.25, 1.0];loop=1;codec="H.264 (High)";frames=2;file="a_0001.png";file="a_0002.png";profile{p3;};layer{index=3;layerSize=true;embedded{id="id-7";};placed=true;};fields=upper;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=true;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=float;data{text="{\"k\":1}";};page=0;alphabetical=false;startTimecode{frame=42;};fileTimecode=none;placedPage=false"#
);
let solid = p.add_item("Solid", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.5, 0.0], width: 64, height: 48, pixel_aspect: 2.0 }));
let mut on_solid = layer.clone();
on_solid.source = LayerSource::Solid { item: solid };
let text = crate::analysis::frames_key::signature_text(p, COMP, &on_solid);
assert_eq!(text.split(";in=").next().unwrap(), format!("frames/1;solid={};solid;color=[1.0, 0.5, 0.0];w=64;h=48;par=2.0", solid.0));
// Every mask mode and motion blur setting gives its own Input Key.
let mask = layer.masks().unwrap().groups().next().unwrap().uid;
let mut keys = std::collections::BTreeSet::new();
for mode in MaskMode::ALL {
for blur in [MaskMotionBlur::SameAsLayer, MaskMotionBlur::On, MaskMotionBlur::Off] {
let mut l = layer.clone();
if let GroupKind::Mask { mode: m, motion_blur, .. } = &mut l.props.find_group_mut(mask).unwrap().kind {
(*m, *motion_blur) = (mode, blur);
}
keys.insert(crate::analysis::frames_key::input_key(p, COMP, &l, ROTO));
}
}
assert_eq!(keys.len(), MaskMode::ALL.len() * 3);
}
+1
View File
@@ -0,0 +1 @@
{"schema":2,"settings":{"bit_depth":"Bpc8","linearize":false,"working_space":null,"blend_linear":false,"time_display":"Timecode","frame_start":0,"audio_sample_rate":48000,"gpu_acceleration":true,"color_engine":"Adobe","hdr":"Clip","output_space":null,"working_gamma":2.2},"items":{"1":{"id":1,"name":"effectcraft-3456-shake.mp4","label":"Aqua","comment":"","parent":null,"kind":{"type":"Footage","path":"/tmp/effectcraft-3456-shake.mp4","kind":"Video","width":160,"height":120,"pixel_aspect":1.0,"frame_rate":{"num":12,"den":1},"native_rate":null,"duration":254016000000,"has_video":true,"has_audio":false,"alpha":"Ignore","premul_color":[0.0,0.0,0.0],"loop_count":1,"codec":"H.264 (High)","missing":false,"color_profile":"Rec709"}},"2":{"id":2,"name":"effectcraft-3456-shake","label":"Sandstone","comment":"","parent":null,"kind":{"type":"Comp","width":160,"height":120,"pixel_aspect":1.0,"frame_rate":{"num":12,"den":1},"duration":254016000000,"display_start":0,"background":[0.0,0.0,0.0],"work_area":[0,254016000000],"layers":[{"id":3,"name":"effectcraft-3456-shake.mp4","source":{"type":"Footage","item":1},"label":"Aqua","comment":"","start_time":0,"in_point":0,"out_point":254016000000,"stretch":100.0,"switches":{"video":true,"audio":true,"solo":false,"locked":false,"shy":false,"collapse":false,"quality":"Best","sampling":"Bilinear","effects":true,"frame_blend":"Off","motion_blur":false,"adjustment":false,"three_d":false,"guide":false},"blend_mode":"Normal","preserve_transparency":false,"track_matte":null,"parent":null,"markers":[],"auto_orient":"Off","props":{"uid":4,"match":"layer","name":"effectcraft-3456-shake.mp4","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":76,"match":"timeRemap","name":"Time Remap","value":{"t":"Scalar","v":0.0},"keys":[{"time":0,"value":{"t":"Scalar","v":0.2},"in_interp":"Linear","out_interp":"Linear","in_ease":[],"out_ease":[],"continuous":false,"auto_bezier":false,"spatial_in":[0.0,0.0,0.0],"spatial_out":[0.0,0.0,0.0],"spatial_auto":true,"spatial_continuous":false,"roving":false},{"time":254016000000,"value":{"t":"Scalar","v":1.0},"in_interp":"Linear","out_interp":"Linear","in_ease":[],"out_ease":[],"continuous":false,"auto_bezier":false,"spatial_in":[0.0,0.0,0.0],"spatial_out":[0.0,0.0,0.0],"spatial_auto":true,"spatial_continuous":false,"roving":false}],"ui":{"kind":"Number"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":5,"match":"masks","name":"Masks","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Group","uid":77,"match":"mask","name":"Mask 1","kind":{"kind":"Mask","mode":"Add","inverted":false,"color":[177,180,79],"locked":false,"motion_blur":"SameAsLayer","feather_falloff":"Smooth","roto_bezier":false},"enabled":true,"children":[{"node":"Prop","uid":78,"match":"path","name":"Mask Path","value":{"t":"Path","v":{"vertices":[[10.0,10.0],[120.0,10.0],[120.0,100.0],[10.0,100.0]],"in_tangents":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"out_tangents":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"closed":false}},"ui":{"kind":"Path"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":79,"match":"feather","name":"Mask Feather","value":{"t":"Vec2","v":[0.0,0.0]},"ui":{"kind":"Pixels"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":80,"match":"opacity","name":"Mask Opacity","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":81,"match":"expansion","name":"Mask Expansion","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Pixels"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Group","uid":6,"match":"effects","name":"Effects","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Group","uid":26,"match":"ec.distort.warpstabilizer","name":"Warp Stabilizer","kind":{"kind":"Effect","effect":"ec.distort.warpstabilizer"},"enabled":true,"children":[{"node":"Group","uid":28,"match":"stabilization","name":"Stabilization","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":27,"match":"result","name":"Result","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Smooth Motion","No Motion"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":29,"match":"smoothness","name":"Smoothness","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":30,"match":"mLine truncated
@@ -0,0 +1,8 @@
asset: analysed-0.24.ecproj
title: The tracked-0.22 clip with Time Remap, a mask, Gaussian Blur and Roto Brush, keyed by EffectCraft 0.24.1
author: arnav-hiwarkar
source: original work
license: MIT OR Apache-2.0
license-file: ../../../../../LICENSE-MIT
added: 2026-11-12 by arnav-hiwarkar
notes: Made from tracked-0.22.ecproj with 0.24.1 engine commands (enable Time Remap, a rectangle mask, Gaussian Blur and Roto Brush), its saved analyses replaced by short stand-in strings, and the Warp Stabilizer, 3D Camera Tracker and Roto Brush keys that 0.24.1 computed for it. Written as compact JSON. No media is needed: the tests only check that the keys are kept or cleared.
@@ -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} }}"));
}