mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
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:
8 files changed
+504
-38
No files matched your search
@@ -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(¶ms);
|
||||
let solve_key = ct::solve_key(&settings);
|
||||
let tracks = ct::tracks(¶ms).filter(|_| tracks_match(&self.project, comp, l, g));
|
||||
let tracks = ct::tracks(¶ms).filter(|_| tracks_match(&self.project, &self.project, comp, l, g));
|
||||
let n = times.len();
|
||||
let work = CamWork {
|
||||
project: self.project.clone(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user