Merge pull request #3564 from arnav-hiwarkar/fix/3535-analysis-inputs

Warp Stabilizer, 3D Camera Tracker: analysis keys cover masks, effects above and precomp contents (#3535)
This commit is contained in:
ash authored and GitHub committed 2026-11-12 17:19:35 +05:30
commit bde8bea2bb
8 files changed
+504 -38

No files matched your search

+13 -11
View File
@@ -26,7 +26,7 @@ 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::analysis::frames_key;
use crate::offload::JobKind;
use crate::tracking::lock;
use crate::warp::analysis_times;
@@ -82,7 +82,7 @@ pub struct CameraJob {
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
/// The layer signature the tracks are made from, and the solve settings key.
/// What the tracks are made from (`tracks_key`), and the solve settings key.
key: String,
solve_key: String,
}
@@ -123,19 +123,21 @@ fn str_of(g: &PropGroup, m: &str) -> String {
}
}
/// What a tracker's tracks are made from: the layer's frames and Detailed Analysis.
/// What a tracker's tracks are made from: its input (the layer's frames under its masks and the
/// effects above it, `frames_key::analysis_key`) and Detailed Analysis.
fn tracks_key(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> String {
tracks_key_of(signature(project, cid, l), g)
tracks_key_of(frames_key::analysis_key(project, cid, l, g.uid), g)
}
fn tracks_key_of(signature: String, g: &PropGroup) -> String {
fn tracks_key_of(input: String, g: &PropGroup) -> String {
let detailed = effectcraft_effects::warp_stab::param(&static_params(g), "advanced/detailedAnalysis").is_some_and(Value::as_bool);
format!("{signature}{}", if detailed { "+detailed" } else { "" })
format!("{input}{}", if detailed { "+detailed" } else { "" })
}
/// 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 {
frames_key::key_matches(project, cid, l, &str_of(g, TRACKS_KEY), |s, _| tracks_key_of(s, g))
/// Whether the tracker's saved tracks are made from its current input and settings, after an
/// edit from `before` to `project` ([`frames_key::analysis_key_matches`]).
fn tracks_match(before: &Project, project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> bool {
frames_key::analysis_key_matches(before, project, cid, l, g.uid, &str_of(g, TRACKS_KEY), |k| tracks_key_of(k, g))
}
/// Parameters of an instance from static values (the tracker's settings are not animated).
@@ -175,7 +177,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_tracker(g)) {
let tracked = !str_of(g, TRACKS).is_empty();
if tracked && !tracks_match(after, *cid, l, g) {
if tracked && !tracks_match(before, after, *cid, l, g) {
clears.push((*cid, l.id, g.uid, true));
todo.push((*cid, l.id, g.uid));
} else if tracked && str_of(g, SOLVE_KEY) != ct::solve_key(&ct::settings(&static_params(g))) {
@@ -335,7 +337,7 @@ impl Session {
let key = tracks_key(&self.project, comp, l, g);
let settings = ct::settings(&params);
let solve_key = ct::solve_key(&settings);
let tracks = ct::tracks(&params).filter(|_| tracks_match(&self.project, comp, l, g));
let tracks = ct::tracks(&params).filter(|_| tracks_match(&self.project, &self.project, comp, l, g));
let n = times.len();
let work = CamWork {
project: self.project.clone(),
+249 -17
View File
@@ -1,27 +1,32 @@
//! 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.
//! What an analysis of a layer is made from. One writer keys the Warp Stabilizer and the 3D
//! Camera Tracker ([`analysis_key`]: frames, masks, effects above, a precomp's contents) and Roto
//! Brush / Object Matte ([`input_key`]: frames, masks, effects above).
//!
//! 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).
//! settings, solid), the Time Remap property, the masks and effect groups (their properties,
//! keyframes and values) and a precomp's comp and layers are destructured or matched in full, and
//! each variant is written as a fixed word (or, for an enum the project file names, its name
//! there): 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 analysed layer itself, only the fields named in `layer_frames` and
//! [`analysis_key`] are read. The text leaves out observations (whether footage is available) and
//! data that never changes pixels (colour labels, UI hints, markers but a comp's timing markers;
//! only expressions read the others), 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::collections::HashSet;
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,
AlphaMode, CineonSettings, CineonUnits, Comp, Expression, FeatherFalloff, FieldOrder, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer,
LayerSource, LightKind, Marker, MaskMode, MaskMotionBlur, Node, PrimitiveKind, Project, PropGroup, Property, Solid, SourceLayer, Switches, TrackMatte, Uid,
essential, styles::GlobalLight,
};
use effectcraft_time::{FrameRate, Tick};
@@ -33,6 +38,10 @@ const VERSION: &str = "frames/1";
/// Property groups nest a few levels (effect ▸ group ▸ property); deeper trees are cut here.
const MAX_DEPTH: usize = 32;
/// Precomps nest deeper than the renderer draws them (16) only in broken projects; deeper
/// contents are cut here.
const MAX_NESTING: usize = 32;
fn fnv(s: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
@@ -54,7 +63,7 @@ pub(crate) enum Era {
}
/// 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
/// stretch, Time Remap and the comp frame rate. Roto Brush Input Keys and [`analysis_key`] are
/// made from it.
pub(crate) fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String {
hex(&signature_text(p, comp, layer))
@@ -79,10 +88,60 @@ 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);
t.inputs_above(layer, uid, true);
hex(&t.0)
}
/// The first word of an [`analysis_key`]'s text: a later change to what it writes bumps it.
const ANALYSIS_VERSION: &str = "analysis/1";
/// What a Warp Stabilizer or 3D Camera Tracker, the instance `uid` of `layer`, analyses: its
/// input, the layer's frames ([`signature`]) under its masks and the effects above it, and a
/// precomp's contents (#3535). The instance's own settings and the effects below it are not part
/// of it.
pub(crate) fn analysis_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
let mut t = Text::new();
t.word(ANALYSIS_VERSION);
t.word(&signature(p, comp, layer));
// The Effects switch turns the effects above on or off (it has none when it is the first).
if layer.effects().and_then(|fx| fx.groups().next()).is_some_and(|g| g.uid != uid) {
t.field("effectsSwitch", layer.switches.effects);
}
// While it is off the renderer skips the whole effect stack: the effects above draw nothing.
t.inputs_above(layer, uid, layer.switches.effects);
t.precomp(p, comp, layer);
hex(&t.0)
}
/// Whether `saved`, the key saved with the analysis of instance `uid` of `layer` in `after`, is
/// for its current input ([`analysis_key`]). `key` makes the saved form from an input key or a
/// frames signature (the tracker adds its Detailed Analysis).
///
/// Keys saved before #3535 hashed only the layer's frames ([`signature`], or a [`legacy`] one).
/// Such a key is taken to be for the input the layer had when it was opened, and stays valid
/// while each edit (from `before` to `after`) keeps the input: the first edit that changes it
/// clears the analysis. A layer or instance new in `after` (pasted, opened) keeps such a key
/// while its frames match.
pub(crate) fn analysis_key_matches(
before: &Project,
after: &Project,
comp: ItemId,
layer: &Layer,
uid: Uid,
saved: &str,
key: impl Fn(String) -> String,
) -> bool {
let now = analysis_key(after, comp, layer, uid);
if key(now.clone()) == saved {
return true;
}
let old = before.comp(comp).and_then(|c| c.layer(layer.id)).filter(|l| l.props.find_group(uid).is_some());
match old {
Some(old) => key_matches(before, comp, old, saved, |s, _| key(s)) && analysis_key(before, comp, old, uid) == now,
None => key_matches(after, comp, layer, saved, |s, _| key(s)),
}
}
/// 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).
@@ -151,7 +210,7 @@ impl Text {
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).
// Its contents are in an [`analysis_key`], not here: Roto Brush keys are made from this.
Some(ItemKind::Comp(c)) => {
self.word("comp");
self.field("w", c.width);
@@ -334,19 +393,192 @@ impl Text {
/// 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) {
/// `effects`: whether to write the effects (a Roto Brush Input Key always does: frozen mattes
/// hash the saved key, so it keeps its text).
fn inputs_above(&mut self, layer: &Layer, uid: Uid, effects: bool) {
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) {
for g in fx.groups().take_while(|g| effects && g.uid != uid) {
self.group(g, 0);
}
}
}
/// A precomp layer's contents: its Essential Properties (they set values inside it) and what
/// its comp draws, nested precomps included.
fn precomp(&mut self, p: &Project, comp: ItemId, layer: &Layer) {
let LayerSource::Comp { item } = layer.source else { return };
if let Some(e) = layer.props.group(essential::GROUP) {
self.word("essential");
self.group(e, 0);
}
// A precomp never draws the comp it is in.
let mut seen = HashSet::from([comp]);
self.comp_contents(p, item, 0, &mut seen);
}
/// What comp `id` draws. Each comp is written once per key, where it is first reached; a
/// later reference (a comp used twice, or a cycle) writes only its id, so the work is bounded
/// by the size of the project.
fn comp_contents(&mut self, p: &Project, id: ItemId, depth: usize, seen: &mut HashSet<ItemId>) {
self.field("contents", id.0);
// Before `seen`: a comp first reached too deep is written in full where it is shallower.
if depth >= MAX_NESTING {
self.word("tooDeep");
return;
}
if !seen.insert(id) {
self.word("seen");
return;
}
let Some(ItemKind::Comp(c)) = p.item(id).map(|i| &i.kind) else {
self.word("none");
return;
};
let Comp {
width,
height,
pixel_aspect,
frame_rate,
duration,
display_start,
background,
// The work area, Hide Shy, the viewer's Resolution and guides, and the poster frame
// only show the comp or say what to preview; they never change its frames.
work_area: _,
layers,
markers,
shutter_angle,
shutter_phase,
motion_blur_samples,
motion_blur_adaptive_limit,
renderer,
hide_shy: _,
enable_motion_blur,
enable_frame_blending,
draft_3d,
preserve_frame_rate,
preserve_resolution,
poster_time: _,
global_light,
guides: _,
essential,
resolution: _,
} = &**c;
self.field("w", width);
self.field("h", height);
self.num("par", *pixel_aspect);
self.rate("rate", *frame_rate);
self.tick("duration", *duration);
self.tick("displayStart", *display_start);
self.floats("background", background);
self.timing_markers(markers);
self.nums("shutter", &[*shutter_angle, *shutter_phase]);
self.field("samples", format_args!("{motion_blur_samples}/{motion_blur_adaptive_limit}"));
self.form("renderer", renderer);
self.field("motionBlur", enable_motion_blur);
self.field("frameBlending", enable_frame_blending);
self.field("draft3d", draft_3d);
self.field("preserveRate", preserve_frame_rate);
self.field("preserveResolution", preserve_resolution);
let GlobalLight { angle, altitude } = global_light;
self.property(angle);
self.property(altitude);
self.opt("essentialGraphics", essential.as_ref(), |t, e| t.form("definition", e));
for l in layers.iter() {
self.nested_layer(p, l, depth, seen);
}
self.word("end");
}
/// A layer of a precomp.
fn nested_layer(&mut self, p: &Project, l: &Layer, depth: usize, seen: &mut HashSet<ItemId>) {
let Layer {
id,
name,
// Whether the name follows the Source Text changes nothing until the name changes.
name_follows_text: _,
source,
// Labels and comments only show the layer.
label: _,
comment: _,
start_time,
in_point,
out_point,
stretch,
switches,
blend_mode,
preserve_transparency,
track_matte,
parent,
// Layer markers annotate it (only expressions read them, as for the analysed layer);
// Lock Markers protects them.
markers: _,
markers_locked: _,
auto_orient,
environment,
environment_background,
props,
} = l;
self.field("layer", id.0);
// Expressions find layers by name (`thisComp.layer("Clip")`), as they find effects.
self.str("name", name);
self.layer_source(source);
if let Some(item) = source.item() {
match p.item(item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => self.footage(f),
Some(ItemKind::Solid(s)) => self.solid(s),
Some(ItemKind::Comp(_)) => self.comp_contents(p, item, depth.saturating_add(1), seen),
Some(ItemKind::Folder) | None => self.word("item=none"),
}
}
self.tick("in", *in_point);
self.tick("out", *out_point);
self.tick("start", *start_time);
self.num("stretch", *stretch);
self.switches(switches);
self.form("blend", blend_mode);
self.field("preserveTransparency", preserve_transparency);
self.opt("matte", track_matte.as_ref(), |t, m| {
let TrackMatte { layer, kind } = m;
t.field("layer", layer.0);
t.form("kind", kind);
});
self.opt("parent", parent.as_ref(), |t, id| t.field("id", id.0));
self.form("autoOrient", auto_orient);
self.field("environment", format_args!("{environment}/{environment_background}"));
self.group(props, 0);
}
fn switches(&mut self, s: &Switches) {
// Lock and Shy only protect and hide the layer in the Timeline. Audio is written (as are
// the Audio group and audio effects, in the property tree): a waveform effect can draw it.
let Switches { video, audio, solo, locked: _, shy: _, collapse, quality, sampling, effects, frame_blend, motion_blur, adjustment, three_d, guide } = s;
self.field("switches", format_args!("{video}/{audio}/{solo}/{collapse}/{effects}/{motion_blur}/{adjustment}/{three_d}/{guide}"));
self.form("quality", quality);
self.form("sampling", sampling);
self.form("frameBlend", frame_blend);
}
/// A comp's protected markers with a duration: they set its timing (Responsive Design).
/// Other markers annotate it; only expressions read them.
fn timing_markers(&mut self, markers: &[Marker]) {
for m in markers.iter().filter(|m| m.protected && m.duration > Tick::ZERO) {
self.tick("timingMarker", m.time);
self.tick("for", m.duration);
}
}
/// A value in its project-file form: enums the project file names, and the Essential Graphics
/// definition.
fn form<T: serde::Serialize>(&mut self, name: &str, v: &T) {
self.str(name, &serde_json::to_string(v).unwrap_or_default());
}
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.
+7 -7
View File
@@ -6,9 +6,9 @@
//! 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; 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
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap, its masks, the
//! effects above the Warp Stabilizer and a precomp's contents: `analysis::frames_key::analysis_key`,
//! shared with the 3D Camera Tracker). 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.
@@ -25,7 +25,7 @@ use effectcraft_time::Tick;
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis};
use serde::Serialize;
use crate::analysis::frames_key::{self, signature};
use crate::analysis::frames_key;
use crate::tracking::lock;
use crate::{Event, Session};
@@ -71,7 +71,7 @@ pub struct WarpJob {
pub comp: ItemId,
pub layer: LayerId,
pub effect: Uid,
/// The layer signature the analysis is made from.
/// What the analysis is made from (`frames_key::analysis_key`).
key: String,
}
@@ -126,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 && !frames_key::key_matches(after, *cid, l, &str_of(g, ANALYSIS_KEY), |s, _| s) {
if analysed && !frames_key::analysis_key_matches(before, after, *cid, l, g.uid, &str_of(g, ANALYSIS_KEY), |k| k) {
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() {
@@ -305,7 +305,7 @@ impl Session {
detailed,
times,
};
let key = signature(&self.project, comp, l);
let key = frames_key::analysis_key(&self.project, comp, l, effect);
if !wait && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::Warp { comp, layer, effect })?;
+1
View File
@@ -1,6 +1,7 @@
mod add_item_options;
mod aep_export;
mod aep_import;
mod analysis_inputs;
mod analysis_signature;
mod anim_presets;
mod anim_tools;
+211
View File
@@ -0,0 +1,211 @@
//! Warp Stabilizer and 3D Camera Tracker analyses follow their input: the layer's frames, its
//! masks, the effects above them and a precomp's contents (#3535). Edits that leave the input
//! as it was (effects below, names, labels) keep them.
use effectcraft_effects::camera_tracker::{TRACKS, TRACKS_KEY};
use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY};
use effectcraft_keyframe::Value;
use effectcraft_project::{ItemId, LayerId, Project};
use serde_json::{Value as Json, json};
use crate::Session;
const COMP: ItemId = ItemId(2);
const CLIP: LayerId = LayerId(3);
const WARP: u64 = 26;
const TRACKER: u64 = 54;
/// `analysed-0.24.ecproj`: the clip of `tracked-0.22.ecproj` with a mask and, in order, a Warp Stabilizer, a 3D Camera
/// Tracker, a Gaussian Blur and a Roto Brush.
const ANALYSED: &str = include_str!("fixtures/analysed-0.24.ecproj");
fn open(json: &str) -> Session {
let mut s = Session::default();
s.replace_project(Project::from_json(json).unwrap(), None);
s.open_comp(COMP);
s
}
/// Which keys an analysis saves.
#[derive(Clone, Copy, Debug)]
enum Saved {
/// Today's: the input to the instance.
Input,
/// Before #3535: the layer's frames only (still recognised).
Frames,
}
/// The key an analysis of instance `uid` (no Detailed Analysis) saves.
fn key(p: &Project, uid: u64, saved: Saved) -> String {
let layer = p.comp(COMP).unwrap().layer(CLIP).unwrap();
match saved {
Saved::Input => crate::analysis::frames_key::analysis_key(p, COMP, layer, uid),
Saved::Frames => crate::analysis::frames_key::signature(p, COMP, layer),
}
}
/// Give the Warp Stabilizer and the tracker an analysis of the clip as it is now, saved with
/// `saved` keys, and reopen the project.
fn analyse(s: &mut Session, saved: Saved) {
let mut p = (*s.project).clone();
let set = |p: &mut Project, uid: u64, data: &str, id: &str, key: &str| {
let g = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(uid).unwrap();
g.prop_mut(data).unwrap().value = Value::Str(format!("saved {data}"));
g.prop_mut(id).unwrap().value = Value::Str(key.into());
};
// The Warp Stabilizer is above the tracker: its analysis is part of the tracker's input.
let k = key(&p, WARP, saved);
set(&mut p, WARP, ANALYSIS, ANALYSIS_KEY, &k);
let k = key(&p, TRACKER, saved);
set(&mut p, TRACKER, TRACKS, TRACKS_KEY, &k);
*s = open(&p.to_json());
run(s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"}));
assert_eq!(analysed(s), [true, true], "{saved:?}");
}
/// Whether the Warp Stabilizer and the tracker still have their analysis.
fn analysed(s: &Session) -> [bool; 2] {
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
let has = |uid: u64, id: &str| matches!(&layer.props.find_group(uid).unwrap().prop(id).unwrap().value, Value::Str(s) if !s.is_empty());
[has(WARP, ANALYSIS), has(TRACKER, TRACKS)]
}
fn run(s: &mut Session, cmd: &str, params: Json) -> Json {
s.execute_checked(cmd, params).unwrap()
}
fn undo(s: &mut Session) {
run(s, "edit.undo", json!({}));
}
/// An edit, its effect on the two analyses, and that undo brings them back.
fn check(s: &mut Session, cmd: &str, params: Json, kept: [bool; 2]) {
assert_eq!(analysed(s), [true, true], "before {cmd} {params}");
run(s, cmd, params.clone());
assert_eq!(analysed(s), kept, "{cmd} {params}");
undo(s);
assert_eq!(analysed(s), [true, true], "undo of {cmd} {params}");
}
#[test]
fn masks_and_effects_above_are_part_of_the_input() {
for saved in [Saved::Input, Saved::Frames] {
masks_and_effects_above(saved);
}
}
fn masks_and_effects_above(saved: Saved) {
let mut s = open(ANALYSED);
analyse(&mut s, saved);
// The layer's mask is under every effect.
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3}), [false, false]);
// The Warp Stabilizer is above the tracker: its settings change the tracker's input only.
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/stabilization/smoothness", "value": 20}), [true, false]);
// The Gaussian Blur and the Roto Brush are below both.
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#3/blurriness", "value": 4}), [true, true]);
run(&mut s, "layer.rename", json!({"layer": CLIP.0, "name": "Renamed"}));
assert_eq!(analysed(&s), [true, true]);
// The Effects switch turns the Warp Stabilizer (above the tracker) off.
check(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": false}), [true, false]);
// While it is off, the effects above draw nothing: editing them keeps the analyses.
run(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": false}));
analyse(&mut s, saved);
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/stabilization/smoothness", "value": 20}), [true, true]);
run(&mut s, "layer.setSwitch", json!({"layers": [CLIP.0], "switch": "effects", "value": true}));
assert_eq!(analysed(&s), [true, false]);
analyse(&mut s, saved);
// An effect moved above the Warp Stabilizer is part of both inputs, and its settings too.
s.edit("blur first", None, |p, _| {
let fx = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.sub_mut("effects").unwrap();
let blur = fx.children.remove(2);
fx.children.insert(0, blur);
Ok(())
})
.unwrap();
assert_eq!(analysed(&s), [false, false]);
analyse(&mut s, saved);
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "effects/#1/blurriness", "value": 4}), [false, false]);
// Saved and reopened, they still match, and a mask edit still clears them.
let mut s = open(&s.project.to_json());
run(&mut s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"}));
assert_eq!(analysed(&s), [true, true]);
check(&mut s, "prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3}), [false, false]);
}
#[test]
fn a_precomps_contents_are_part_of_the_input() {
for saved in [Saved::Input, Saved::Frames] {
precomp_contents(saved);
}
}
fn precomp_contents(saved: Saved) {
let mut s = open(ANALYSED);
// Pre-compose the clip, leaving its masks and effects on the precomp layer.
let r = run(&mut s, "layer.precompose", json!({"layers": [CLIP.0], "mode": "leave"}));
let inner = ItemId(r["comp"].as_u64().unwrap());
let inner_layer = |s: &Session, comp: ItemId| s.project.comp(comp).unwrap().layers[0].id.0;
let clip = inner_layer(&s, inner);
analyse(&mut s, saved);
// Edits inside the precomp change the frames.
check(&mut s, "prop.set", json!({"comp": inner.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]);
check(&mut s, "effect.apply", json!({"comp": inner.0, "layers": [clip], "effect": "ec.blur.gaussian"}), [false, false]);
check(&mut s, "layer.timing", json!({"comp": inner.0, "layers": [clip], "out": 0.25}), [false, false]);
// A label or comment inside it doesn't. A name does: expressions find layers by name.
s.edit("label", None, |p, _| {
let l = p.comp_mut(inner).unwrap().layer_mut(LayerId(clip)).unwrap();
l.label = effectcraft_color::Label::Pink;
l.comment = "a note".into();
Ok(())
})
.unwrap();
assert_eq!(analysed(&s), [true, true]);
check(&mut s, "layer.rename", json!({"comp": inner.0, "layer": clip, "name": "Inner"}), [false, false]);
// A precomp inside the precomp: its contents too.
let r = run(&mut s, "layer.precompose", json!({"comp": inner.0, "layers": [clip], "mode": "leave"}));
let deeper = ItemId(r["comp"].as_u64().unwrap());
assert_eq!(analysed(&s), [false, false], "pre-composing inside the precomp changes its contents");
analyse(&mut s, saved);
let clip = inner_layer(&s, deeper);
check(&mut s, "prop.set", json!({"comp": deeper.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]);
// Saved and reopened, they still match, and an edit inside still clears them.
let mut s = open(&s.project.to_json());
run(&mut s, "renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"}));
assert_eq!(analysed(&s), [true, true]);
check(&mut s, "prop.set", json!({"comp": deeper.0, "layer": clip, "path": "transform/opacity", "value": 50}), [false, false]);
}
/// Precomps used many times, nested deeply or (in a broken project) in a cycle are keyed in
/// bounded time, and an edit deep inside still changes the key.
#[test]
fn shared_deep_and_cyclic_precomps_are_keyed_in_bounded_time() {
use effectcraft_color::Label;
use effectcraft_project::{ItemKind, LayerSource};
let mut s = open(ANALYSED);
let r = run(&mut s, "layer.precompose", json!({"layers": [CLIP.0], "mode": "leave"}));
let inner = ItemId(r["comp"].as_u64().unwrap());
let mut p = (*s.project).clone();
let template = p.comp(inner).unwrap().clone();
// 40 comps, each drawing the next one twice (2^40 paths if each were walked), the last
// drawing the first.
let ids: Vec<ItemId> = (0..40).map(|i| p.add_item(&format!("Nest {i}"), Label::default(), None, ItemKind::Comp(template.clone().into()))).collect();
for (i, id) in ids.iter().enumerate() {
let next = ids[(i + 1) % ids.len()];
let mut layer = template.layers[0].clone();
layer.source = LayerSource::Comp { item: next };
let c = p.comp_mut(*id).unwrap();
c.layers.push(layer.clone());
layer.id = LayerId(layer.id.0 + 1000);
c.layers.push(layer);
}
p.comp_mut(inner).unwrap().layers[0].source = LayerSource::Comp { item: ids[0] };
let key = |p: &Project| crate::analysis::frames_key::analysis_key(p, COMP, p.comp(COMP).unwrap().layer(CLIP).unwrap(), WARP);
let start = std::time::Instant::now();
let before = key(&p);
assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed());
p.comp_mut(ids[10]).unwrap().background = [1.0, 0.0, 0.0];
assert_ne!(key(&p), before);
}
+9
View File
@@ -252,6 +252,15 @@ fn invalidation_resolve_cancel_and_undo() {
assert_eq!(status(&mut s)["analyzed"], false);
assert!(s.redo());
assert_eq!(status(&mut s)["solved"], true);
// A mask is part of the tracked input (#3535): drawing one clears the tracks, undo keeps them.
s.execute_checked("mask.new", json!({"layer": clip.0, "vertices": [[10, 10], [200, 10], [200, 150]]})).unwrap();
assert_eq!(status(&mut s)["analyzed"], false);
s.execute_checked("edit.undo", json!({})).unwrap();
assert_eq!(status(&mut s)["solved"], true);
// An effect below the tracker isn't.
s.execute_checked("effect.apply", json!({"layers": [clip.0], "effect": "ec.blur.gaussian"})).unwrap();
assert_eq!(status(&mut s)["solved"], true);
s.execute_checked("edit.undo", json!({})).unwrap();
// Changing the Solve Method keeps the tracks and only re-solves.
s.execute_checked("prop.set", json!({"layer": clip.0, "path": "effects/#1/advanced/solveMethod", "value": 1})).unwrap();
+9
View File
@@ -391,6 +391,15 @@ fn warp_analysis_invalidates_and_cancels() {
let fx = r["effect"].as_u64().unwrap();
let cid = s.active_comp_id().unwrap();
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
// A mask is part of the analysed input (#3535): drawing one clears the analysis, undo keeps it.
s.execute_checked("mask.new", json!({"layer": clip.0, "vertices": [[10, 10], [200, 10], [200, 150]]})).unwrap();
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], false);
s.execute_checked("edit.undo", json!({})).unwrap();
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
// An effect below it isn't.
s.execute_checked("effect.apply", json!({"layers": [clip.0], "effect": "ec.blur.gaussian"})).unwrap();
assert_eq!(s.execute("warp.status", json!({})).unwrap()["analyzed"], true);
s.execute_checked("edit.undo", json!({})).unwrap();
// Trimming the layer clears the analysis and queues it again.
s.execute("layer.timing", json!({"layers": [clip.0], "out": 0.8})).unwrap();
let st = s.execute("warp.status", json!({})).unwrap();
+5 -3
View File
@@ -492,8 +492,9 @@ between the layer's In and Out points in the background ("Analyzing in backgroun
then "Stabilizing..."; `warp.status` reports `progress`, `banner`, whether the effect is
`analyzed`, and the current frame's `warp` matrix, `autoScale` and `crop`); `warp.cancel` stops it
without writing anything. The analysis is stored in the effect (saved with the project) and is
one undo step; trimming, slipping, time-remapping or replacing the layer's source clears it and
the app re-analyses automatically (headless: run `warp.analyze` again). Settings are ordinary
one undo step; trimming, slipping, time-remapping or replacing the layer's source, editing its
masks, the effects above the Warp Stabilizer or, on a precomp layer, the precomp's contents
clears it and the app re-analyses automatically (headless: run `warp.analyze` again). Settings are ordinary
properties under the instance's groups, e.g. `effects/#1/stabilization/result` (0 Smooth Motion,
1 No Motion), `effects/#1/stabilization/smoothness`, `effects/#1/stabilization/method`,
`effects/#1/borders/framing` (0 Stabilize Only … 3 Stabilize, Synthesize Edges),
@@ -540,7 +541,8 @@ Each edit is one undo step; the first create adds the one-node "3D Tracker Camer
startFrame?: 1, includePoints?: true}` exports the solve as an executable Blender scene script:
baked camera/lens/focus, synced video background, points and ground. See
[Blender camera export](blender-camera-export.md) for framing, assumptions and limitations.
Changing the layer's frames clears the analysis; changing `effects/#1/shotType`,
Changing the layer's frames, masks, the effects above the tracker or a precomp's contents
clears the analysis; changing `effects/#1/shotType`,
`effects/#1/horizontalAngleOfView`, `effects/#1/advanced/solveMethod` re-solves the stored tracks.
```sh