mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
#3535: Warp Stabilizer and 3D Camera Tracker keys cover a precomp's contents
On a precomp layer the key covered only the nested comp's size, rate and duration, so an edit inside the precomp kept a stale analysis. The key now writes the precomp layer's Essential Properties and what its comp draws, field by field and recursively. Each comp is written once per key and nesting is capped, so shared, deep or cyclic precomps are keyed in bounded time. Docs say what clears an analysis.
This commit is contained in:
5 files changed
+276
-21
No files matched your search
@@ -2,26 +2,29 @@
|
||||
//! 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
|
||||
//! (names, 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 (names, labels, UI hints, markers), so they never drop saved
|
||||
//! analyses (#3534, #3536).
|
||||
//!
|
||||
//! Keys saved by 0.22–0.24 hashed derived `Debug` text instead. [`legacy`] still recognises
|
||||
//! them, and a matching saved key is kept rather than rewritten: frozen Roto Brush mattes and
|
||||
//! segmentations hash the saved key itself.
|
||||
|
||||
use std::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 +36,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 +61,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))
|
||||
@@ -87,8 +94,9 @@ pub(crate) fn input_key_of(signature: String, layer: &Layer, uid: Uid) -> String
|
||||
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 (#3535).
|
||||
/// The instance's own settings and the effects below it are not part of it.
|
||||
/// 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);
|
||||
@@ -98,6 +106,7 @@ pub(crate) fn analysis_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -
|
||||
t.field("effectsSwitch", layer.switches.effects);
|
||||
}
|
||||
t.inputs_above(layer, uid);
|
||||
t.precomp(p, comp, layer);
|
||||
hex(&t.0)
|
||||
}
|
||||
|
||||
@@ -198,7 +207,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);
|
||||
@@ -394,6 +403,173 @@ impl Text {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
// Names, labels and comments only show the layer. (An expression can find a layer
|
||||
// by its name, as it can an effect, which the key leaves out too.)
|
||||
name: _,
|
||||
name_follows_text: _,
|
||||
source,
|
||||
label: _,
|
||||
comment: _,
|
||||
start_time,
|
||||
in_point,
|
||||
out_point,
|
||||
stretch,
|
||||
switches,
|
||||
blend_mode,
|
||||
preserve_transparency,
|
||||
track_matte,
|
||||
parent,
|
||||
// Layer markers annotate it; Lock Markers protects them.
|
||||
markers: _,
|
||||
markers_locked: _,
|
||||
auto_orient,
|
||||
environment,
|
||||
environment_background,
|
||||
props,
|
||||
} = l;
|
||||
self.field("layer", id.0);
|
||||
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.
|
||||
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.
|
||||
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) {
|
||||
// The uid, name and colour label identify and label the group; they never change pixels.
|
||||
let PropGroup { uid: _, match_id, name: _, kind, enabled, label: _, children } = g;
|
||||
|
||||
@@ -6,10 +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, its masks and
|
||||
//! the effects above the Warp Stabilizer: `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
|
||||
//! 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.
|
||||
|
||||
|
||||
@@ -126,3 +126,72 @@ fn masks_and_effects_above(saved: Saved) {
|
||||
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 name, label or comment inside it doesn't.
|
||||
run(&mut s, "layer.rename", json!({"comp": inner.0, "layer": clip, "name": "Inner"}));
|
||||
assert_eq!(analysed(&s), [true, true]);
|
||||
|
||||
// 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();
|
||||
|
||||
+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