mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
Merge pull request #3550 from arnav-hiwarkar/fix/3534-3536-analysis-signatures
Warp Stabilizer, 3D Camera Tracker, Roto Brush: one explicit analysis key; offline footage keeps analyses (#3534, #3536)
This commit is contained in:
12 files changed
+1042
-181
No files matched your search
@@ -133,6 +133,7 @@ configuration from these Apache-2.0 projects:
|
||||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/engine/src/tests/fixtures/analysed-0.24.ecproj` | arnav-hiwarkar | Original work, synthetic project keyed by EffectCraft 0.24.1 | MIT OR Apache-2.0 |
|
||||
| `crates/engine/src/tests/fixtures/object-matte-0.22.ecproj` | lisn0 | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 |
|
||||
| `crates/engine/src/tests/fixtures/tracked-0.22.ecproj` | arnav-hiwarkar | Original work, synthetic project saved by EffectCraft 0.22.0 | MIT OR Apache-2.0 |
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod camera_track;
|
||||
pub(crate) mod frames_key;
|
||||
pub mod mask_track;
|
||||
pub mod models;
|
||||
pub mod roto;
|
||||
|
||||
@@ -26,9 +26,10 @@ use effectcraft_track::camtrack::linalg::{self, Similarity, V3};
|
||||
use effectcraft_track::camtrack::{AnalyzeOpts, CameraSolve, CameraTracks, SolveSettings, Target, TrackAnalyzer};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::analysis::frames_key::{self, signature};
|
||||
use crate::offload::JobKind;
|
||||
use crate::tracking::lock;
|
||||
use crate::warp::{analysis_times, signature};
|
||||
use crate::warp::analysis_times;
|
||||
use crate::{Event, Session};
|
||||
|
||||
/// Live analysis progress (serde for agents / the control channel).
|
||||
@@ -134,7 +135,7 @@ fn tracks_key_of(signature: String, g: &PropGroup) -> String {
|
||||
|
||||
/// Whether the tracker's saved tracks are made from the layer's current frames and settings.
|
||||
fn tracks_match(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> bool {
|
||||
crate::warp::key_matches(project, cid, l, &str_of(g, TRACKS_KEY), |s| tracks_key_of(s, g))
|
||||
frames_key::key_matches(project, cid, l, &str_of(g, TRACKS_KEY), |s, _| tracks_key_of(s, g))
|
||||
}
|
||||
|
||||
/// Parameters of an instance from static values (the tracker's settings are not animated).
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
//! What an analysis of a layer is made from, as one key shared by the Warp Stabilizer, the 3D
|
||||
//! Camera Tracker and Roto Brush / Object Matte.
|
||||
//!
|
||||
//! The key hashes a text this module writes field by field. The source item (footage and its
|
||||
//! settings, solid), the Time Remap property and the masks and effect groups (their properties,
|
||||
//! keyframes and values) are destructured or matched in full, and each variant is written as a
|
||||
//! fixed word: a new field or variant doesn't compile until it is decided here, written if it
|
||||
//! changes the layer's frames, left out (with a reason) if it doesn't. Renaming a field or variant
|
||||
//! doesn't change keys. Of the layer and of a precomp, only the fields named in `layer_frames` are
|
||||
//! read (a precomp's contents are #3535). The
|
||||
//! text leaves out observations (whether footage is available) and data that never changes pixels
|
||||
//! (colour labels, UI hints, markers), so they never drop saved analyses (#3534, #3536).
|
||||
//!
|
||||
//! Keys saved by 0.22–0.24 hashed derived `Debug` text instead. [`legacy`] still recognises
|
||||
//! them, and a matching saved key is kept rather than rewritten: frozen Roto Brush mattes and
|
||||
//! segmentations hash the saved key itself.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use effectcraft_color::ColorSpace;
|
||||
use effectcraft_keyframe::{Ease, FeatherPoint, Gradient, Interp, Keyframe, ShapePath, Value};
|
||||
use effectcraft_project::{
|
||||
AlphaMode, CineonSettings, CineonUnits, Expression, FeatherFalloff, FieldOrder, Footage, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerSource,
|
||||
LightKind, MaskMode, MaskMotionBlur, Node, PrimitiveKind, Project, PropGroup, Property, Solid, SourceLayer, Uid,
|
||||
};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
pub(crate) mod legacy;
|
||||
|
||||
/// The version of the text, its first word: a later change to what it writes bumps it.
|
||||
const VERSION: &str = "frames/1";
|
||||
|
||||
/// Property groups nest a few levels (effect ▸ group ▸ property); deeper trees are cut here.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
fn fnv(s: &str) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn hex(s: &str) -> String {
|
||||
format!("{:016x}", fnv(s))
|
||||
}
|
||||
|
||||
/// Which signature a key is made from: today's, or one an earlier release saved.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Era {
|
||||
Current,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
/// The signature of `layer`'s frames: its source (and the source item), In/Out, start time,
|
||||
/// stretch, Time Remap and the comp frame rate. Warp Stabilizer and 3D Camera Tracker keys are
|
||||
/// made from it.
|
||||
pub(crate) fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String {
|
||||
hex(&signature_text(p, comp, layer))
|
||||
}
|
||||
|
||||
/// The text [`signature`] hashes.
|
||||
pub(crate) fn signature_text(p: &Project, comp: ItemId, layer: &Layer) -> String {
|
||||
let mut t = Text::new();
|
||||
t.word(VERSION);
|
||||
t.layer_frames(p, comp, layer);
|
||||
t.0
|
||||
}
|
||||
|
||||
/// A Roto Brush's (or Object Matte's) Input Key: the layer's frames, its masks and the effects
|
||||
/// above the instance `uid`.
|
||||
pub(crate) fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
|
||||
input_key_of(signature(p, comp, layer), layer, uid)
|
||||
}
|
||||
|
||||
/// An Input Key from a [`signature`].
|
||||
pub(crate) fn input_key_of(signature: String, layer: &Layer, uid: Uid) -> String {
|
||||
let mut t = Text::new();
|
||||
t.word(VERSION);
|
||||
t.word(&signature);
|
||||
t.inputs_above(layer, uid);
|
||||
hex(&t.0)
|
||||
}
|
||||
|
||||
/// Whether `saved`, a key saved with an analysis of `layer`, is for the layer's current frames.
|
||||
/// `key` makes such a key from a signature of the given era. A key an earlier release saved for
|
||||
/// the same frames matches too: keep it rather than rewrite it (analysing writes today's).
|
||||
pub(crate) fn key_matches(p: &Project, comp: ItemId, layer: &Layer, saved: &str, key: impl Fn(String, Era) -> String) -> bool {
|
||||
key(signature(p, comp, layer), Era::Current) == saved || legacy::signatures(p, comp, layer).into_iter().any(|s| key(s, Era::Legacy) == saved)
|
||||
}
|
||||
|
||||
/// The text a key is hashed from. Fields are `name=value;`; strings are quoted and escaped, so
|
||||
/// no value can end its field early.
|
||||
struct Text(String);
|
||||
|
||||
impl Text {
|
||||
fn new() -> Text {
|
||||
Text(String::new())
|
||||
}
|
||||
|
||||
fn word(&mut self, w: &str) {
|
||||
self.0.push_str(w);
|
||||
self.0.push(';');
|
||||
}
|
||||
|
||||
fn field(&mut self, name: &str, value: impl std::fmt::Display) {
|
||||
let _ = write!(self.0, "{name}={value};");
|
||||
}
|
||||
|
||||
fn str(&mut self, name: &str, s: &str) {
|
||||
let _ = write!(self.0, "{name}={s:?};");
|
||||
}
|
||||
|
||||
fn num(&mut self, name: &str, v: f64) {
|
||||
let _ = write!(self.0, "{name}={v:?};");
|
||||
}
|
||||
|
||||
fn nums(&mut self, name: &str, v: &[f64]) {
|
||||
let _ = write!(self.0, "{name}={v:?};");
|
||||
}
|
||||
|
||||
fn floats(&mut self, name: &str, v: &[f32]) {
|
||||
let _ = write!(self.0, "{name}={v:?};");
|
||||
}
|
||||
|
||||
fn opt<T>(&mut self, name: &str, v: Option<&T>, write: impl FnOnce(&mut Text, &T)) {
|
||||
match v {
|
||||
Some(v) => {
|
||||
let _ = write!(self.0, "{name}{{");
|
||||
write(self, v);
|
||||
self.0.push_str("};");
|
||||
}
|
||||
None => self.word(&format!("{name}=none")),
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self, name: &str, t: Tick) {
|
||||
self.field(name, t.0);
|
||||
}
|
||||
|
||||
fn rate(&mut self, name: &str, r: FrameRate) {
|
||||
let FrameRate { num, den } = r;
|
||||
let _ = write!(self.0, "{name}={num}/{den};");
|
||||
}
|
||||
|
||||
/// What a layer's frames are made of, before its masks and effects.
|
||||
fn layer_frames(&mut self, p: &Project, comp: ItemId, layer: &Layer) {
|
||||
self.layer_source(&layer.source);
|
||||
if let Some(item) = layer.source.item() {
|
||||
match p.item(item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => self.footage(f),
|
||||
Some(ItemKind::Solid(s)) => self.solid(s),
|
||||
// A precomp's own contents aren't in the signature yet (#3535).
|
||||
Some(ItemKind::Comp(c)) => {
|
||||
self.word("comp");
|
||||
self.field("w", c.width);
|
||||
self.field("h", c.height);
|
||||
self.rate("rate", c.frame_rate);
|
||||
self.tick("duration", c.duration);
|
||||
}
|
||||
Some(ItemKind::Folder) | None => self.word("item=none"),
|
||||
}
|
||||
}
|
||||
self.tick("in", layer.in_point);
|
||||
self.tick("out", layer.out_point);
|
||||
self.tick("start", layer.start_time);
|
||||
self.num("stretch", layer.stretch);
|
||||
self.opt("timeRemap", layer.props.get("timeRemap"), |t, pr| t.property(pr));
|
||||
self.opt("compRate", p.comp(comp).map(|c| &c.frame_rate), |t, r| t.rate("rate", *r));
|
||||
}
|
||||
|
||||
fn layer_source(&mut self, s: &LayerSource) {
|
||||
match s {
|
||||
LayerSource::Footage { item } => self.field("footage", item.0),
|
||||
LayerSource::Comp { item } => self.field("comp", item.0),
|
||||
LayerSource::Solid { item } => self.field("solid", item.0),
|
||||
LayerSource::Model { item } => self.field("model", item.0),
|
||||
LayerSource::Text => self.word("text"),
|
||||
LayerSource::Shape => self.word("shape"),
|
||||
LayerSource::Null => self.word("null"),
|
||||
LayerSource::Camera => self.word("camera"),
|
||||
LayerSource::Light { kind } => self.field(
|
||||
"light",
|
||||
match kind {
|
||||
LightKind::Parallel => "parallel",
|
||||
LightKind::Spot => "spot",
|
||||
LightKind::Point => "point",
|
||||
LightKind::Ambient => "ambient",
|
||||
LightKind::Environment => "environment",
|
||||
},
|
||||
),
|
||||
LayerSource::Primitive { kind } => self.field(
|
||||
"primitive",
|
||||
match kind {
|
||||
PrimitiveKind::Cube => "cube",
|
||||
PrimitiveKind::Sphere => "sphere",
|
||||
PrimitiveKind::Plane => "plane",
|
||||
PrimitiveKind::Torus => "torus",
|
||||
PrimitiveKind::Cone => "cone",
|
||||
PrimitiveKind::Cylinder => "cylinder",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn solid(&mut self, s: &Solid) {
|
||||
let Solid { color, width, height, pixel_aspect } = s;
|
||||
self.word("solid");
|
||||
self.floats("color", color);
|
||||
self.field("w", width);
|
||||
self.field("h", height);
|
||||
self.num("par", *pixel_aspect);
|
||||
}
|
||||
|
||||
fn footage(&mut self, f: &Footage) {
|
||||
let Footage {
|
||||
path,
|
||||
kind,
|
||||
width,
|
||||
height,
|
||||
pixel_aspect,
|
||||
frame_rate,
|
||||
native_rate,
|
||||
duration,
|
||||
has_video,
|
||||
has_audio,
|
||||
alpha,
|
||||
premul_color,
|
||||
loop_count,
|
||||
codec,
|
||||
// Whether the file is there is an observation (`footage.check`), not an edit: an
|
||||
// offline file is the same footage, and its analyses stay valid (#3534).
|
||||
missing: _,
|
||||
sequence,
|
||||
color_profile,
|
||||
layer,
|
||||
fields,
|
||||
invert_alpha,
|
||||
linear_light,
|
||||
preserve_rgb,
|
||||
cineon,
|
||||
data,
|
||||
page,
|
||||
alphabetical,
|
||||
start_timecode,
|
||||
file_timecode,
|
||||
// Markers annotate the footage; they never change its frames.
|
||||
markers: _,
|
||||
placed_page,
|
||||
} = f;
|
||||
self.word("footage");
|
||||
self.str("path", path);
|
||||
self.field(
|
||||
"kind",
|
||||
match kind {
|
||||
FootageKind::Video => "video",
|
||||
FootageKind::Still => "still",
|
||||
FootageKind::Sequence => "sequence",
|
||||
FootageKind::Audio => "audio",
|
||||
FootageKind::Model => "model",
|
||||
FootageKind::Data => "data",
|
||||
},
|
||||
);
|
||||
self.field("w", width);
|
||||
self.field("h", height);
|
||||
self.num("par", *pixel_aspect);
|
||||
self.rate("rate", *frame_rate);
|
||||
self.opt("nativeRate", native_rate.as_ref(), |t, r| t.rate("rate", *r));
|
||||
self.tick("duration", *duration);
|
||||
self.field("video", has_video);
|
||||
self.field("audio", has_audio);
|
||||
self.field(
|
||||
"alpha",
|
||||
match alpha {
|
||||
AlphaMode::Straight => "straight",
|
||||
AlphaMode::Premultiplied => "premultiplied",
|
||||
AlphaMode::Ignore => "ignore",
|
||||
},
|
||||
);
|
||||
self.floats("matte", premul_color);
|
||||
self.field("loop", loop_count);
|
||||
self.str("codec", codec);
|
||||
self.field("frames", sequence.len());
|
||||
for s in sequence {
|
||||
self.str("file", s);
|
||||
}
|
||||
self.opt("profile", color_profile.as_ref(), |t, c| t.word(color_space(*c)));
|
||||
self.opt("layer", layer.as_ref(), |t, l| t.source_layer(l));
|
||||
self.field(
|
||||
"fields",
|
||||
match fields {
|
||||
FieldOrder::Off => "off",
|
||||
FieldOrder::UpperFirst => "upper",
|
||||
FieldOrder::LowerFirst => "lower",
|
||||
},
|
||||
);
|
||||
self.field("invertAlpha", invert_alpha);
|
||||
self.field("linearLight", linear_light);
|
||||
self.field("preserveRgb", preserve_rgb);
|
||||
self.cineon(cineon);
|
||||
self.opt("data", data.as_ref(), |t, d| t.str("text", d));
|
||||
self.field("page", page);
|
||||
self.field("alphabetical", alphabetical);
|
||||
self.opt("startTimecode", start_timecode.as_ref(), |t, v| t.field("frame", v));
|
||||
self.opt("fileTimecode", file_timecode.as_ref(), |t, v| t.field("frame", v));
|
||||
self.field("placedPage", placed_page);
|
||||
}
|
||||
|
||||
fn source_layer(&mut self, l: &SourceLayer) {
|
||||
let SourceLayer { index, name, layer_size, embedded, placed } = l;
|
||||
self.field("index", index);
|
||||
// The layer is found by its index; the name only labels it.
|
||||
let _ = name;
|
||||
self.field("layerSize", layer_size);
|
||||
self.opt("embedded", embedded.as_ref(), |t, e| t.str("id", e));
|
||||
self.field("placed", placed);
|
||||
}
|
||||
|
||||
fn cineon(&mut self, c: &CineonSettings) {
|
||||
let CineonSettings { log_conversion, converted_black, converted_white, black, white, gamma, rolloff, units } = c;
|
||||
self.word("cineon");
|
||||
self.field("log", log_conversion);
|
||||
self.floats("points", &[*converted_black, *converted_white, *black, *white, *gamma, *rolloff]);
|
||||
self.field(
|
||||
"units",
|
||||
match units {
|
||||
CineonUnits::TenBit => "10bit",
|
||||
CineonUnits::EightBit => "8bit",
|
||||
CineonUnits::Float => "float",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The layer's masks and the effects above the instance `uid`: what the effect's input is
|
||||
/// made of besides the layer's frames.
|
||||
fn inputs_above(&mut self, layer: &Layer, uid: Uid) {
|
||||
if let Some(m) = layer.masks() {
|
||||
self.word("masks");
|
||||
self.group(m, 0);
|
||||
}
|
||||
if let Some(fx) = layer.effects() {
|
||||
self.word("effects");
|
||||
for g in fx.groups().take_while(|g| g.uid != uid) {
|
||||
self.group(g, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn group(&mut self, g: &PropGroup, depth: usize) {
|
||||
// Expressions find effects and groups by name (`effect("Blur")`, `propertyGroup(1).name`).
|
||||
// The uid and colour label never change pixels.
|
||||
let PropGroup { uid: _, match_id, name, kind, enabled, label: _, children } = g;
|
||||
if depth >= MAX_DEPTH {
|
||||
self.word("tooDeep");
|
||||
return;
|
||||
}
|
||||
self.str("group", match_id);
|
||||
self.str("name", name);
|
||||
self.group_kind(kind);
|
||||
self.field("enabled", enabled);
|
||||
for c in children {
|
||||
match c {
|
||||
Node::Prop(p) => self.property(p),
|
||||
Node::Group(g) => self.group(g, depth + 1),
|
||||
}
|
||||
}
|
||||
self.word("end");
|
||||
}
|
||||
|
||||
fn group_kind(&mut self, k: &GroupKind) {
|
||||
match k {
|
||||
GroupKind::Plain => self.word("plain"),
|
||||
GroupKind::Indexed => self.word("indexed"),
|
||||
// A mask's colour and lock only show it in the UI.
|
||||
GroupKind::Mask { mode, inverted, color: _, locked: _, motion_blur, feather_falloff, roto_bezier } => {
|
||||
self.field(
|
||||
"mask",
|
||||
match mode {
|
||||
MaskMode::None => "none",
|
||||
MaskMode::Add => "add",
|
||||
MaskMode::Subtract => "subtract",
|
||||
MaskMode::Intersect => "intersect",
|
||||
MaskMode::Lighten => "lighten",
|
||||
MaskMode::Darken => "darken",
|
||||
MaskMode::Difference => "difference",
|
||||
},
|
||||
);
|
||||
self.field("inverted", inverted);
|
||||
self.field(
|
||||
"motionBlur",
|
||||
match motion_blur {
|
||||
MaskMotionBlur::SameAsLayer => "layer",
|
||||
MaskMotionBlur::On => "on",
|
||||
MaskMotionBlur::Off => "off",
|
||||
},
|
||||
);
|
||||
self.field(
|
||||
"falloff",
|
||||
match feather_falloff {
|
||||
FeatherFalloff::Smooth => "smooth",
|
||||
FeatherFalloff::Linear => "linear",
|
||||
},
|
||||
);
|
||||
self.field("rotoBezier", roto_bezier);
|
||||
}
|
||||
GroupKind::Effect { effect } => self.str("effect", effect),
|
||||
// A motion tracker's settings say how to track, not how to draw the layer.
|
||||
GroupKind::Tracker { settings: _ } => self.word("tracker"),
|
||||
GroupKind::Essential { overridden } => {
|
||||
self.word("essential");
|
||||
for u in overridden {
|
||||
self.field("overridden", u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn property(&mut self, pr: &Property) {
|
||||
// Expressions read the name (`thisProperty.name`) and seed `random`/`wiggle` with the
|
||||
// uid: the uid is written for a property with an expression. UI hints and the 2D/3D-only
|
||||
// and hold-only flags say where and how it can be edited, not what it evaluates to.
|
||||
let Property { uid, match_id, name, value, keys, expr, ui: _, spatial, hold_only: _, three_d_only: _, two_d_only: _, shown_dims: _, static_only: _ } =
|
||||
pr;
|
||||
self.str("prop", match_id);
|
||||
self.str("name", name);
|
||||
if expr.is_some() {
|
||||
self.field("uid", uid);
|
||||
}
|
||||
self.value(value);
|
||||
self.field("spatial", spatial);
|
||||
for k in keys {
|
||||
self.keyframe(k);
|
||||
}
|
||||
self.opt("expr", expr.as_ref(), |t, e| {
|
||||
let Expression { text, enabled } = e;
|
||||
t.str("text", text);
|
||||
t.field("enabled", enabled);
|
||||
});
|
||||
}
|
||||
|
||||
fn keyframe(&mut self, k: &Keyframe) {
|
||||
// A key's colour label only shows it in the UI.
|
||||
let Keyframe {
|
||||
time,
|
||||
value,
|
||||
in_interp,
|
||||
out_interp,
|
||||
in_ease,
|
||||
out_ease,
|
||||
continuous,
|
||||
auto_bezier,
|
||||
spatial_in,
|
||||
spatial_out,
|
||||
spatial_auto,
|
||||
spatial_continuous,
|
||||
roving,
|
||||
label: _,
|
||||
muted,
|
||||
} = k;
|
||||
self.tick("key", *time);
|
||||
self.value(value);
|
||||
self.field("interp", format_args!("{}/{}", interp(*in_interp), interp(*out_interp)));
|
||||
for (side, eases) in [("inEase", in_ease), ("outEase", out_ease)] {
|
||||
for e in eases {
|
||||
let Ease { speed, influence } = e;
|
||||
self.nums(side, &[*speed, *influence]);
|
||||
}
|
||||
}
|
||||
self.field("continuous", continuous);
|
||||
self.field("autoBezier", auto_bezier);
|
||||
self.nums("spatialIn", spatial_in);
|
||||
self.nums("spatialOut", spatial_out);
|
||||
self.field("spatialAuto", spatial_auto);
|
||||
self.field("spatialContinuous", spatial_continuous);
|
||||
self.field("roving", roving);
|
||||
self.field("muted", muted);
|
||||
}
|
||||
|
||||
fn value(&mut self, v: &Value) {
|
||||
match v {
|
||||
Value::Scalar(x) => self.num("scalar", *x),
|
||||
Value::Vec2(x) => self.nums("vec2", x),
|
||||
Value::Vec3(x) => self.nums("vec3", x),
|
||||
Value::Color(x) => self.nums("color", x),
|
||||
Value::Bool(b) => self.field("bool", b),
|
||||
Value::Enum(i) => self.field("enum", i),
|
||||
Value::Path(p) => self.shape_path(p),
|
||||
// A text document has dozens of fields and no place in what a key covers today (a
|
||||
// text layer's Source Text is not under its masks or effects): its project-file form.
|
||||
Value::Text(d) => self.str("text", &serde_json::to_string(d).unwrap_or_default()),
|
||||
Value::Layer(l) => self.opt("layer", l.as_ref(), |t, id| t.field("id", id)),
|
||||
Value::Gradient(g) => {
|
||||
let Gradient { colors, opacities } = g;
|
||||
self.word("gradient");
|
||||
for (at, c) in colors {
|
||||
self.num("stop", *at);
|
||||
self.floats("rgba", c);
|
||||
}
|
||||
for (at, o) in opacities {
|
||||
self.num("stop", *at);
|
||||
self.floats("opacity", &[*o]);
|
||||
}
|
||||
}
|
||||
Value::Str(s) => self.str("str", s),
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_path(&mut self, p: &ShapePath) {
|
||||
let ShapePath { vertices, in_tangents, out_tangents, closed, feather } = p;
|
||||
self.word("path");
|
||||
for (name, points) in [("v", vertices), ("in", in_tangents), ("out", out_tangents)] {
|
||||
for pt in points {
|
||||
self.nums(name, pt);
|
||||
}
|
||||
}
|
||||
self.field("closed", closed);
|
||||
for f in feather {
|
||||
let FeatherPoint { segment, t, radius, tension } = f;
|
||||
self.field("featherSegment", segment);
|
||||
self.nums("feather", &[*t, *radius, *tension]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn interp(i: Interp) -> &'static str {
|
||||
match i {
|
||||
Interp::Linear => "linear",
|
||||
Interp::Bezier => "bezier",
|
||||
Interp::Hold => "hold",
|
||||
}
|
||||
}
|
||||
|
||||
fn color_space(c: ColorSpace) -> &'static str {
|
||||
match c {
|
||||
ColorSpace::Srgb => "srgb",
|
||||
ColorSpace::Rec709 => "rec709",
|
||||
ColorSpace::Rec2020 => "rec2020",
|
||||
ColorSpace::DisplayP3 => "p3",
|
||||
ColorSpace::AcesCg => "acescg",
|
||||
ColorSpace::Aces2065 => "aces2065",
|
||||
ColorSpace::Rec2100Pq => "rec2100pq",
|
||||
ColorSpace::Rec2100Hlg => "rec2100hlg",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Signatures as 0.22–0.24 wrote them, to recognise the keys those releases saved. Nothing new is
|
||||
//! keyed with them. They hash derived `Debug` text, so `tests::analysis_signature` pins keys
|
||||
//! those releases saved: if a type change alters this text, write that part out here.
|
||||
|
||||
use effectcraft_project::{Footage, ItemId, ItemKind, Layer, LayerSource, Project, Uid};
|
||||
|
||||
use super::fnv;
|
||||
|
||||
/// The signatures a saved key of `layer` may have been made from. The footage's availability is
|
||||
/// tried both ways: those releases hashed it, though it isn't part of the footage (#3534). Before
|
||||
/// `Footage::placed_page` was added in 0.23, the footage text ended without that field.
|
||||
pub(crate) fn signatures(p: &Project, comp: ItemId, layer: &Layer) -> Vec<String> {
|
||||
let src = match &layer.source {
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => {
|
||||
let mut out = Vec::with_capacity(4);
|
||||
for missing in [false, true] {
|
||||
out.push(signature_with_source(p, comp, layer, &footage_text(f, missing, true)));
|
||||
if matches!(layer.source, LayerSource::Footage { .. }) && !f.placed_page {
|
||||
out.push(signature_with_source(p, comp, layer, &footage_text(f, missing, false)));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Some(ItemKind::Solid(s)) => format!("{s:?}"),
|
||||
Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration),
|
||||
_ => String::new(),
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
vec![signature_with_source(p, comp, layer, &src)]
|
||||
}
|
||||
|
||||
/// A Roto Brush Input Key as 0.22–0.24 made it from a [`signatures`] entry.
|
||||
pub(crate) fn input_key_of(mut s: String, layer: &Layer, uid: Uid) -> String {
|
||||
if let Some(m) = layer.masks() {
|
||||
s.push_str(&format!("{m:?}"));
|
||||
}
|
||||
if let Some(fx) = layer.effects() {
|
||||
for g in fx.groups() {
|
||||
if g.uid == uid {
|
||||
break;
|
||||
}
|
||||
s.push_str(&format!("{g:?}"));
|
||||
}
|
||||
}
|
||||
format!("{:016x}", fnv(&s))
|
||||
}
|
||||
|
||||
/// A footage item as 0.23–0.24 wrote it into a signature (without `placed_page` before 0.23),
|
||||
/// with `missing` in place of the item's.
|
||||
pub(crate) fn footage_text(f: &Footage, missing: bool, placed_page: bool) -> String {
|
||||
format!("{:?}", FootageText { f, missing, placed_page })
|
||||
}
|
||||
|
||||
struct FootageText<'a> {
|
||||
f: &'a Footage,
|
||||
missing: bool,
|
||||
placed_page: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FootageText<'_> {
|
||||
fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// Every field is named so that a new one doesn't compile until it is decided here: a new
|
||||
// field was never in these keys, so it stays out.
|
||||
let Footage {
|
||||
path,
|
||||
kind,
|
||||
width,
|
||||
height,
|
||||
pixel_aspect,
|
||||
frame_rate,
|
||||
native_rate,
|
||||
duration,
|
||||
has_video,
|
||||
has_audio,
|
||||
alpha,
|
||||
premul_color,
|
||||
loop_count,
|
||||
codec,
|
||||
missing: _,
|
||||
sequence,
|
||||
color_profile,
|
||||
layer,
|
||||
fields,
|
||||
invert_alpha,
|
||||
linear_light,
|
||||
preserve_rgb,
|
||||
cineon,
|
||||
data,
|
||||
page,
|
||||
alphabetical,
|
||||
start_timecode,
|
||||
file_timecode,
|
||||
markers,
|
||||
placed_page,
|
||||
} = self.f;
|
||||
let mut d = out.debug_struct("Footage");
|
||||
d.field("path", path)
|
||||
.field("kind", kind)
|
||||
.field("width", width)
|
||||
.field("height", height)
|
||||
.field("pixel_aspect", pixel_aspect)
|
||||
.field("frame_rate", frame_rate)
|
||||
.field("native_rate", native_rate)
|
||||
.field("duration", duration)
|
||||
.field("has_video", has_video)
|
||||
.field("has_audio", has_audio)
|
||||
.field("alpha", alpha)
|
||||
.field("premul_color", premul_color)
|
||||
.field("loop_count", loop_count)
|
||||
.field("codec", codec)
|
||||
.field("missing", &self.missing)
|
||||
.field("sequence", sequence)
|
||||
.field("color_profile", color_profile)
|
||||
.field("layer", layer)
|
||||
.field("fields", fields)
|
||||
.field("invert_alpha", invert_alpha)
|
||||
.field("linear_light", linear_light)
|
||||
.field("preserve_rgb", preserve_rgb)
|
||||
.field("cineon", cineon)
|
||||
.field("data", data)
|
||||
.field("page", page)
|
||||
.field("alphabetical", alphabetical)
|
||||
.field("start_timecode", start_timecode)
|
||||
.field("file_timecode", file_timecode)
|
||||
.field("markers", markers);
|
||||
if self.placed_page {
|
||||
d.field("placed_page", placed_page);
|
||||
}
|
||||
d.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String {
|
||||
let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default();
|
||||
let s = format!(
|
||||
"{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}",
|
||||
layer.source,
|
||||
layer.in_point,
|
||||
layer.out_point,
|
||||
layer.start_time,
|
||||
layer.stretch,
|
||||
layer.props.get("timeRemap")
|
||||
);
|
||||
format!("{:016x}", fnv(&s))
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
//! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same
|
||||
//! pass and stores the final mattes in the effect (one undo step). `sync`, run after every
|
||||
//! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made
|
||||
//! of (the layer's source and timing, its masks and the effects above it) and drops frozen
|
||||
//! mattes made from other frames.
|
||||
//! of (the layer's source and timing, its masks and the effects above it: `analysis::frames_key`),
|
||||
//! and drops frozen mattes made from other frames. Footage going offline keeps them.
|
||||
//!
|
||||
//! Object Matte instances run through the same jobs (`objectMatte.propagate` / `.freeze`): the
|
||||
//! frames are [`om::job_frames`] around the reference frame, each segmented through
|
||||
@@ -31,6 +31,7 @@ use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpt
|
||||
use effectcraft_time::Tick;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::analysis::frames_key::{self, Era};
|
||||
use crate::tracking::lock;
|
||||
use crate::{Event, Session};
|
||||
|
||||
@@ -207,34 +208,10 @@ fn set_str(g: &mut PropGroup, m: &str, v: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fn fnv(s: &str) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// What the effect's input frames are made of: the layer's source and timing, its masks and the
|
||||
/// effects above the Roto Brush.
|
||||
pub fn input_key(p: &Project, comp: ItemId, layer: &Layer, uid: Uid) -> String {
|
||||
input_key_with_signature(crate::warp::signature(p, comp, layer), layer, uid)
|
||||
}
|
||||
|
||||
fn input_key_with_signature(mut s: String, layer: &Layer, uid: Uid) -> String {
|
||||
if let Some(m) = layer.masks() {
|
||||
s.push_str(&format!("{m:?}"));
|
||||
}
|
||||
if let Some(fx) = layer.effects() {
|
||||
for g in fx.groups() {
|
||||
if g.uid == uid {
|
||||
break;
|
||||
}
|
||||
s.push_str(&format!("{g:?}"));
|
||||
}
|
||||
}
|
||||
format!("{:016x}", fnv(&s))
|
||||
frames_key::input_key(p, comp, layer, uid)
|
||||
}
|
||||
|
||||
/// After an edit: refresh Input Keys (Roto Brush and Object Matte); frozen mattes made from
|
||||
@@ -252,7 +229,11 @@ pub(crate) fn sync(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerI
|
||||
let old = before.comp(*cid).and_then(|bc| bc.layer(l.id)).and_then(|ol| ol.props.find_group(g.uid));
|
||||
let saved_key = str_of(g, INPUT_KEY);
|
||||
// Keep a matching old key: the frozen cache itself also hashes that key.
|
||||
if !crate::warp::key_matches(after, *cid, l, &saved_key, |s| input_key_with_signature(s, l, g.uid)) {
|
||||
let key_of = |s, era| match era {
|
||||
Era::Current => frames_key::input_key_of(s, l, g.uid),
|
||||
Era::Legacy => frames_key::legacy::input_key_of(s, l, g.uid),
|
||||
};
|
||||
if !frames_key::key_matches(after, *cid, l, &saved_key, key_of) {
|
||||
set.push((*cid, l.id, g.uid, key));
|
||||
todo.push((*cid, l.id, g.uid));
|
||||
} else if old.is_none_or(|o| guard_of(o) != guard_of(g)) {
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
//! analyzer (step 1 of 2, "Analyzing in background"); step 2 ("Stabilizing") derives the
|
||||
//! stabilization plan once to validate and cache the result. When the job finishes, the analysis is
|
||||
//! written into the effect's hidden Analysis parameter as one undo step, together with a key of
|
||||
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap). Edits that
|
||||
//! change those clear the analysis (`invalidate`, run after every edit) and queue the effect
|
||||
//! for re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
|
||||
//! automatically too).
|
||||
//! what it was made from (the layer's source, In/Out, start, stretch and Time Remap; the key is
|
||||
//! `analysis::frames_key`'s, shared with the 3D Camera Tracker and Roto Brush). Edits that change
|
||||
//! those clear the analysis (`invalidate`, run after every edit) and queue the effect for
|
||||
//! re-analysis, which frontends start with [`Session::poll_warp`] (After Effects re-analyses
|
||||
//! automatically too). Footage going offline is not such a change.
|
||||
|
||||
use crate::offload::{JobKind, rate};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -17,13 +18,14 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_effects::warp_stab::{self, ANALYSIS, ANALYSIS_KEY};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Footage, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Project, PropGroup, Uid};
|
||||
use effectcraft_project::{GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_track::stabilize::{AnalyzeOpts, Analyzer, WarpAnalysis};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::analysis::frames_key::{self, signature};
|
||||
use crate::tracking::lock;
|
||||
use crate::{Event, Session};
|
||||
|
||||
@@ -98,148 +100,6 @@ pub(crate) struct WarpWork {
|
||||
pub times: Vec<Tick>,
|
||||
}
|
||||
|
||||
fn fnv(s: &str) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// What an analysis of `layer` depends on (its frames): source (and the footage item's
|
||||
/// interpretation), In/Out, start time, stretch, Time Remap and the comp frame rate.
|
||||
pub fn signature(p: &Project, comp: ItemId, layer: &Layer) -> String {
|
||||
let src = match &layer.source {
|
||||
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match p.item(*item).map(|i| &i.kind) {
|
||||
Some(ItemKind::Footage(f)) => footage_text(f, true),
|
||||
Some(ItemKind::Solid(s)) => format!("{s:?}"),
|
||||
Some(ItemKind::Comp(c)) => format!("{}x{}@{:?}/{:?}", c.width, c.height, c.frame_rate, c.duration),
|
||||
_ => String::new(),
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
signature_with_source(p, comp, layer, &src)
|
||||
}
|
||||
|
||||
/// Whether `saved`, a key an analysis of `layer` was saved with, is for the layer's current frames.
|
||||
/// `key` makes such a key from a signature. A key made from the signature an earlier release wrote
|
||||
/// for the same frames matches too: keep it rather than rewrite it (analysing writes today's).
|
||||
pub(crate) fn key_matches(p: &Project, comp: ItemId, layer: &Layer, saved: &str, key: impl Fn(String) -> String) -> bool {
|
||||
key(signature(p, comp, layer)) == saved || signature_before_placed_page(p, comp, layer).is_some_and(|s| key(s) == saved)
|
||||
}
|
||||
|
||||
/// Before `Footage::placed_page` was added in 0.23, the persisted signature hashed the
|
||||
/// same footage text without that last field. Only ordinary footage can match it.
|
||||
fn signature_before_placed_page(p: &Project, comp: ItemId, layer: &Layer) -> Option<String> {
|
||||
let LayerSource::Footage { item } = &layer.source else { return None };
|
||||
let ItemKind::Footage(footage) = &p.item(*item)?.kind else { return None };
|
||||
if footage.placed_page {
|
||||
return None;
|
||||
}
|
||||
Some(signature_with_source(p, comp, layer, &footage_text(footage, false)))
|
||||
}
|
||||
|
||||
/// A footage item in a signature: its fields as `Debug` wrote them in 0.23, the text saved
|
||||
/// analysis keys were hashed from (without `placed_page` before 0.23). It is written out here so
|
||||
/// that a new `Footage` field doesn't change every signature and drop every saved analysis.
|
||||
pub(crate) fn footage_text(f: &Footage, placed_page: bool) -> String {
|
||||
format!("{:?}", FootageText { f, placed_page })
|
||||
}
|
||||
|
||||
struct FootageText<'a> {
|
||||
f: &'a Footage,
|
||||
placed_page: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FootageText<'_> {
|
||||
fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// Every field is named, so a new one doesn't compile until it is decided here. A new field
|
||||
// that changes the frames goes last, written only when it isn't its default, so keys saved
|
||||
// before it still match. A new field that doesn't change the frames stays out.
|
||||
let Footage {
|
||||
path,
|
||||
kind,
|
||||
width,
|
||||
height,
|
||||
pixel_aspect,
|
||||
frame_rate,
|
||||
native_rate,
|
||||
duration,
|
||||
has_video,
|
||||
has_audio,
|
||||
alpha,
|
||||
premul_color,
|
||||
loop_count,
|
||||
codec,
|
||||
missing,
|
||||
sequence,
|
||||
color_profile,
|
||||
layer,
|
||||
fields,
|
||||
invert_alpha,
|
||||
linear_light,
|
||||
preserve_rgb,
|
||||
cineon,
|
||||
data,
|
||||
page,
|
||||
alphabetical,
|
||||
start_timecode,
|
||||
file_timecode,
|
||||
markers,
|
||||
placed_page,
|
||||
} = self.f;
|
||||
let mut d = out.debug_struct("Footage");
|
||||
d.field("path", path)
|
||||
.field("kind", kind)
|
||||
.field("width", width)
|
||||
.field("height", height)
|
||||
.field("pixel_aspect", pixel_aspect)
|
||||
.field("frame_rate", frame_rate)
|
||||
.field("native_rate", native_rate)
|
||||
.field("duration", duration)
|
||||
.field("has_video", has_video)
|
||||
.field("has_audio", has_audio)
|
||||
.field("alpha", alpha)
|
||||
.field("premul_color", premul_color)
|
||||
.field("loop_count", loop_count)
|
||||
.field("codec", codec)
|
||||
.field("missing", missing)
|
||||
.field("sequence", sequence)
|
||||
.field("color_profile", color_profile)
|
||||
.field("layer", layer)
|
||||
.field("fields", fields)
|
||||
.field("invert_alpha", invert_alpha)
|
||||
.field("linear_light", linear_light)
|
||||
.field("preserve_rgb", preserve_rgb)
|
||||
.field("cineon", cineon)
|
||||
.field("data", data)
|
||||
.field("page", page)
|
||||
.field("alphabetical", alphabetical)
|
||||
.field("start_timecode", start_timecode)
|
||||
.field("file_timecode", file_timecode)
|
||||
.field("markers", markers);
|
||||
if self.placed_page {
|
||||
d.field("placed_page", placed_page);
|
||||
}
|
||||
d.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn signature_with_source(p: &Project, comp: ItemId, layer: &Layer, src: &str) -> String {
|
||||
let rate = p.comp(comp).map(|c| format!("{:?}", c.frame_rate)).unwrap_or_default();
|
||||
let s = format!(
|
||||
"{:?}|{src}|{:?}|{:?}|{:?}|{}|{:?}|{rate}",
|
||||
layer.source,
|
||||
layer.in_point,
|
||||
layer.out_point,
|
||||
layer.start_time,
|
||||
layer.stretch,
|
||||
layer.props.get("timeRemap")
|
||||
);
|
||||
format!("{:016x}", fnv(&s))
|
||||
}
|
||||
|
||||
fn is_warp(g: &PropGroup) -> bool {
|
||||
matches!(&g.kind, GroupKind::Effect { effect } if effect == warp_stab::ID)
|
||||
}
|
||||
@@ -266,7 +126,7 @@ pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId,
|
||||
let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id));
|
||||
for g in fx.groups().filter(|g| is_warp(g)) {
|
||||
let analysed = !str_of(g, ANALYSIS).is_empty();
|
||||
if analysed && !key_matches(after, *cid, l, &str_of(g, ANALYSIS_KEY), |s| s) {
|
||||
if analysed && !frames_key::key_matches(after, *cid, l, &str_of(g, ANALYSIS_KEY), |s, _| s) {
|
||||
clear.push((*cid, l.id, g.uid));
|
||||
todo.push((*cid, l.id, g.uid));
|
||||
} else if !analysed && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod add_item_options;
|
||||
mod aep_export;
|
||||
mod aep_import;
|
||||
mod analysis_signature;
|
||||
mod anim_presets;
|
||||
mod anim_tools;
|
||||
mod aux_footage;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Warp Stabilizer, 3D Camera Tracker and Roto Brush keys saved by 0.24 keep matching their
|
||||
//! layer's frames after edits that don't change them, and a real change still clears them.
|
||||
|
||||
use effectcraft_effects::camera_tracker::{TRACKS, TRACKS_KEY};
|
||||
use effectcraft_effects::roto::{FROZEN, INPUT_KEY};
|
||||
use effectcraft_effects::warp_stab::{ANALYSIS, ANALYSIS_KEY};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{ItemId, LayerId, Project};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const COMP: ItemId = ItemId(2);
|
||||
const CLIP: LayerId = LayerId(3);
|
||||
const WARP: u64 = 26;
|
||||
const TRACKER: u64 = 54;
|
||||
const ROTO: u64 = 86;
|
||||
|
||||
/// Saved by 0.24.1 (before #3534/#3536): the clip of `tracked-0.22.ecproj` with Time Remap, a
|
||||
/// mask, a Gaussian Blur and a Roto Brush, its analyses replaced by short stand-ins. The keys
|
||||
/// are the ones that version computed for it.
|
||||
const ANALYSED_0_24: &str = include_str!("fixtures/analysed-0.24.ecproj");
|
||||
const SIGNATURE_0_24: &str = "03efb7c4b0f27b65";
|
||||
const ROTO_INPUT_0_24: &str = "7b319ce93027617c";
|
||||
|
||||
fn open(json: &str) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.replace_project(Project::from_json(json).unwrap(), None);
|
||||
s.open_comp(COMP);
|
||||
s
|
||||
}
|
||||
|
||||
/// The (data, key) of the Warp Stabilizer, the 3D Camera Tracker and the Roto Brush.
|
||||
fn saved(s: &Session) -> [(String, String); 3] {
|
||||
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
|
||||
let get = |uid: u64, data: &str, key: &str| {
|
||||
let params = crate::camera_track::static_params(layer.props.find_group(uid).unwrap());
|
||||
let text = |id: &str| match params.get(id) {
|
||||
Some(Value::Str(s)) => s.clone(),
|
||||
other => panic!("{id}: {other:?}"),
|
||||
};
|
||||
(text(data), text(key))
|
||||
};
|
||||
[get(WARP, ANALYSIS, ANALYSIS_KEY), get(TRACKER, TRACKS, TRACKS_KEY), get(ROTO, FROZEN, INPUT_KEY)]
|
||||
}
|
||||
|
||||
fn add_to_render_queue(s: &mut Session) {
|
||||
s.execute_checked("renderQueue.add", json!({"comp": COMP.0, "format": "png", "output": "frame.png"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyses_saved_by_0_24_survive_edits_that_keep_their_frames() {
|
||||
let mut s = open(ANALYSED_0_24);
|
||||
let before = saved(&s);
|
||||
assert_eq!(before[0], ("saved analysis".into(), SIGNATURE_0_24.into()));
|
||||
assert_eq!(before[1], ("saved tracks".into(), SIGNATURE_0_24.into()));
|
||||
assert_eq!(before[2], ("saved frozen".into(), ROTO_INPUT_0_24.into()));
|
||||
|
||||
add_to_render_queue(&mut s);
|
||||
assert_eq!(saved(&s), before);
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(saved(&s), before);
|
||||
|
||||
// Saved and reopened, the keys still match.
|
||||
let mut s = open(&s.project.to_json());
|
||||
add_to_render_queue(&mut s);
|
||||
assert_eq!(saved(&s), before);
|
||||
|
||||
// Other frames: all three are cleared (the Roto Brush gets a new key), and undo brings them back.
|
||||
s.execute_checked("layer.timing", json!({"layer": CLIP.0, "out": 0.25})).unwrap();
|
||||
let after = saved(&s);
|
||||
assert_eq!(after[0], (String::new(), String::new()));
|
||||
assert_eq!(after[1], (String::new(), String::new()));
|
||||
assert_eq!(after[2].0, "");
|
||||
assert_ne!(after[2].1, ROTO_INPUT_0_24);
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(saved(&s), before);
|
||||
|
||||
// Time Remap is part of the frames.
|
||||
s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "timeRemap", "value": 0.3})).unwrap();
|
||||
assert!(saved(&s).iter().all(|(data, _)| data.is_empty()));
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
|
||||
// The Roto Brush's input also has the layer's masks and the effects above it.
|
||||
s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "effects/#3/blurriness", "value": 4})).unwrap();
|
||||
let after = saved(&s);
|
||||
assert_eq!(after[2].0, "");
|
||||
assert_ne!(after[2].1, ROTO_INPUT_0_24);
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"layer": CLIP.0, "path": "masks/#1/expansion", "value": 3})).unwrap();
|
||||
assert_eq!(saved(&s)[2].0, "");
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(saved(&s), before);
|
||||
}
|
||||
|
||||
/// The text keys are made from today, written field by field (#3536). It changes only when this
|
||||
/// test is changed with it: a new field of a type it writes doesn't compile until it is decided
|
||||
/// in `analysis::frames_key`, and renaming a field or variant doesn't change it.
|
||||
#[test]
|
||||
fn signature_text_is_pinned() {
|
||||
let s = open(ANALYSED_0_24);
|
||||
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
|
||||
let key = r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=video;w=160;h=120;par=1.0;rate=12/1;nativeRate=none;duration=254016000000;video=true;audio=false;alpha=ignore;matte=[0.0, 0.0, 0.0];loop=1;codec="H.264 (High)";frames=0;profile{rec709;};layer=none;fields=off;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=false;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=10bit;data=none;page=0;alphabetical=false;startTimecode=none;fileTimecode=none;placedPage=false;in=0;out=254016000000;start=0;stretch=100.0;timeRemap{prop="timeRemap";name="Time Remap";scalar=0.0;spatial=false;key=0;scalar=0.2;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;key=254016000000;scalar=1.0;interp=linear/linear;continuous=false;autoBezier=false;spatialIn=[0.0, 0.0, 0.0];spatialOut=[0.0, 0.0, 0.0];spatialAuto=true;spatialContinuous=false;roving=false;muted=false;expr=none;};compRate{rate=12/1;};"#;
|
||||
assert_eq!(crate::analysis::frames_key::signature_text(&s.project, COMP, layer), key);
|
||||
}
|
||||
|
||||
/// Keys made today (after analysing again) match through edits, undo and save/reopen too, and
|
||||
/// renaming or relabelling never drops them.
|
||||
#[test]
|
||||
fn todays_keys_survive_edits_that_keep_their_frames() {
|
||||
let mut s = open(ANALYSED_0_24);
|
||||
// Analysing again writes today's keys.
|
||||
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
|
||||
let signature = crate::analysis::frames_key::signature(&s.project, COMP, layer);
|
||||
let input = crate::roto::input_key(&s.project, COMP, layer, ROTO);
|
||||
assert_ne!(signature, SIGNATURE_0_24);
|
||||
s.edit("analyse", None, |p, _| {
|
||||
let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap();
|
||||
for (uid, id, key) in [(WARP, ANALYSIS_KEY, &signature), (TRACKER, TRACKS_KEY, &signature), (ROTO, INPUT_KEY, &input)] {
|
||||
l.props.find_group_mut(uid).unwrap().get_mut(id).unwrap().value = Value::Str(key.clone());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// The Roto Brush's key covers the effects above it: the Warp Stabilizer's new key changed it.
|
||||
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap();
|
||||
let input = crate::roto::input_key(&s.project, COMP, layer, ROTO);
|
||||
s.edit("roto key", None, |p, _| {
|
||||
let g = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(ROTO).unwrap();
|
||||
g.get_mut(INPUT_KEY).unwrap().value = Value::Str(input.clone());
|
||||
g.get_mut(FROZEN).unwrap().value = Value::Str("saved frozen".into());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = saved(&s);
|
||||
assert_eq!(before[0].1, signature);
|
||||
assert_eq!(before[2], ("saved frozen".into(), input));
|
||||
|
||||
add_to_render_queue(&mut s);
|
||||
assert_eq!(saved(&s), before);
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(saved(&s), before);
|
||||
let mut s = open(&s.project.to_json());
|
||||
add_to_render_queue(&mut s);
|
||||
assert_eq!(saved(&s), before);
|
||||
// Offline and back online (#3534).
|
||||
for missing in [true, false] {
|
||||
if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) {
|
||||
f.missing = missing;
|
||||
}
|
||||
add_to_render_queue(&mut s);
|
||||
assert_eq!(saved(&s), before);
|
||||
}
|
||||
// Colour labels and footage markers only show things.
|
||||
s.edit("label", None, |p, _| {
|
||||
let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap();
|
||||
l.props.find_group_mut(82).unwrap().label = Some(effectcraft_color::Label::Yellow);
|
||||
if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) {
|
||||
f.markers.push(effectcraft_project::Marker::default());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(saved(&s), before);
|
||||
// Expressions find effects by name: renaming the effect above the Roto Brush is a change for it.
|
||||
s.edit("rename", None, |p, _| {
|
||||
p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap().props.find_group_mut(82).unwrap().name = "Soft".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(saved(&s)[..2], before[..2]);
|
||||
assert_eq!(saved(&s)[2].0, "");
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(saved(&s), before);
|
||||
|
||||
// A real change still clears them.
|
||||
s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap();
|
||||
assert!(saved(&s).iter().all(|(data, _)| data.is_empty()));
|
||||
}
|
||||
|
||||
/// Footage going offline (or coming back) is an observation, not an edit: the next edit keeps
|
||||
/// every analysis (#3534).
|
||||
#[test]
|
||||
fn offline_footage_keeps_analyses() {
|
||||
let mut s = open(ANALYSED_0_24);
|
||||
let before = saved(&s);
|
||||
let missing = |s: &Session| matches!(&s.project.item(ItemId(1)).unwrap().kind, effectcraft_project::ItemKind::Footage(f) if f.missing);
|
||||
// The fixture's footage path doesn't exist here: the check marks it missing.
|
||||
let r = s.execute_checked("footage.check", json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["missing"], 1, "{r}");
|
||||
assert!(missing(&s));
|
||||
add_to_render_queue(&mut s);
|
||||
assert_eq!(saved(&s), before);
|
||||
|
||||
// Saved and reopened while offline, then back online.
|
||||
let mut s = open(&s.project.to_json());
|
||||
assert!(missing(&s));
|
||||
add_to_render_queue(&mut s);
|
||||
assert_eq!(saved(&s), before);
|
||||
if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) {
|
||||
f.missing = false;
|
||||
}
|
||||
add_to_render_queue(&mut s);
|
||||
assert_eq!(saved(&s), before);
|
||||
|
||||
// A real change while offline still clears them.
|
||||
s.execute_checked("file.interpretFootage", json!({"items": [1], "invertAlpha": true})).unwrap();
|
||||
assert!(saved(&s).iter().all(|(data, _)| data.is_empty()));
|
||||
}
|
||||
|
||||
/// A key 0.24.1 saved while the footage was offline (it hashed the availability) matches too,
|
||||
/// online or offline. 0.24.1 gave the Roto Brush this Input Key when the fixture's footage went
|
||||
/// offline and an edit cleared the other two analyses.
|
||||
#[test]
|
||||
fn keys_saved_by_0_24_while_offline_still_match() {
|
||||
const ROTO_INPUT_0_24_OFFLINE: &str = "6183fdd46b514259";
|
||||
let mut s = open(ANALYSED_0_24);
|
||||
s.edit("offline", None, |p, _| {
|
||||
if let Some(effectcraft_project::ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) {
|
||||
f.missing = true;
|
||||
}
|
||||
let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap();
|
||||
// What 0.24.1's edit cleared then.
|
||||
use effectcraft_effects::camera_tracker::{AVERAGE_ERROR, METHOD_USED, SOLVE, SOLVE_KEY};
|
||||
for (uid, id) in
|
||||
[(WARP, ANALYSIS), (WARP, ANALYSIS_KEY), (TRACKER, SOLVE), (TRACKER, SOLVE_KEY), (TRACKER, METHOD_USED), (TRACKER, TRACKS), (TRACKER, TRACKS_KEY)]
|
||||
{
|
||||
l.props.find_group_mut(uid).unwrap().prop_mut(id).unwrap().value = Value::Str(String::new());
|
||||
}
|
||||
l.props.find_group_mut(TRACKER).unwrap().prop_mut(AVERAGE_ERROR).unwrap().value = Value::Scalar(0.0);
|
||||
let g = l.props.find_group_mut(ROTO).unwrap();
|
||||
g.get_mut(INPUT_KEY).unwrap().value = Value::Str(ROTO_INPUT_0_24_OFFLINE.into());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let roto = |s: &Session| saved(s)[2].clone();
|
||||
assert_eq!(roto(&s), ("saved frozen".into(), ROTO_INPUT_0_24_OFFLINE.into()));
|
||||
for missing in [true, false] {
|
||||
if let Some(effectcraft_project::ItemKind::Footage(f)) = std::sync::Arc::make_mut(&mut s.project).item_mut(ItemId(1)).map(|i| &mut i.kind) {
|
||||
f.missing = missing;
|
||||
}
|
||||
add_to_render_queue(&mut s);
|
||||
assert_eq!(roto(&s), ("saved frozen".into(), ROTO_INPUT_0_24_OFFLINE.into()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Expressions seed `random` and `wiggle` with the property's uid: a Time Remap with an expression
|
||||
/// keys its uid (a duplicated layer gets new uids), one without doesn't.
|
||||
#[test]
|
||||
fn an_expression_keys_its_property_uid() {
|
||||
let s = open(ANALYSED_0_24);
|
||||
let layer = s.project.comp(COMP).unwrap().layer(CLIP).unwrap().clone();
|
||||
let text = |l: &effectcraft_project::Layer| crate::analysis::frames_key::signature_text(&s.project, COMP, l);
|
||||
let renumbered = |l: &effectcraft_project::Layer| {
|
||||
let mut l = l.clone();
|
||||
l.props.get_mut("timeRemap").unwrap().uid += 1000;
|
||||
l
|
||||
};
|
||||
assert_eq!(text(&renumbered(&layer)), text(&layer));
|
||||
let mut with_expr = layer.clone();
|
||||
with_expr.props.get_mut("timeRemap").unwrap().expr =
|
||||
Some(effectcraft_project::Expression { text: "seedRandom(0, true); random(0, 1)".into(), enabled: true });
|
||||
assert_ne!(text(&renumbered(&with_expr)), text(&with_expr));
|
||||
}
|
||||
|
||||
/// Non-default values of the types the key writes are written as their fixed words (#3536).
|
||||
#[test]
|
||||
fn nested_values_are_written_as_fixed_words() {
|
||||
use effectcraft_project::{
|
||||
AlphaMode, CineonUnits, FieldOrder, FootageKind, GroupKind, ItemKind, LayerSource, MaskMode, MaskMotionBlur, Solid, SourceLayer,
|
||||
};
|
||||
let mut s = open(ANALYSED_0_24);
|
||||
let p = std::sync::Arc::make_mut(&mut s.project);
|
||||
if let Some(ItemKind::Footage(f)) = p.item_mut(ItemId(1)).map(|i| &mut i.kind) {
|
||||
f.kind = FootageKind::Sequence;
|
||||
f.sequence = vec!["a_0001.png".into(), "a_0002.png".into()];
|
||||
f.alpha = AlphaMode::Premultiplied;
|
||||
f.premul_color = [0.5, 0.25, 1.0];
|
||||
f.fields = FieldOrder::UpperFirst;
|
||||
f.cineon.units = CineonUnits::Float;
|
||||
f.cineon.log_conversion = true;
|
||||
f.color_profile = Some(effectcraft_color::ColorSpace::DisplayP3);
|
||||
f.layer = Some(SourceLayer { index: 3, name: "Sky".into(), layer_size: true, embedded: Some("id-7".into()), placed: true });
|
||||
f.native_rate = Some(effectcraft_time::FrameRate { num: 30000, den: 1001 });
|
||||
f.start_timecode = Some(42);
|
||||
f.data = Some("{\"k\":1}".into());
|
||||
}
|
||||
let layer = p.comp(COMP).unwrap().layer(CLIP).unwrap().clone();
|
||||
let text = crate::analysis::frames_key::signature_text(p, COMP, &layer);
|
||||
let footage = text.split(";in=").next().unwrap();
|
||||
assert_eq!(
|
||||
footage,
|
||||
r#"frames/1;footage=1;footage;path="/tmp/effectcraft-3456-shake.mp4";kind=sequence;w=160;h=120;par=1.0;rate=12/1;nativeRate{rate=30000/1001;};duration=254016000000;video=true;audio=false;alpha=premultiplied;matte=[0.5, 0.25, 1.0];loop=1;codec="H.264 (High)";frames=2;file="a_0001.png";file="a_0002.png";profile{p3;};layer{index=3;layerSize=true;embedded{id="id-7";};placed=true;};fields=upper;invertAlpha=false;linearLight=false;preserveRgb=false;cineon;log=true;points=[0.0, 1023.0, 95.0, 685.0, 1.7, 20.0];units=float;data{text="{\"k\":1}";};page=0;alphabetical=false;startTimecode{frame=42;};fileTimecode=none;placedPage=false"#
|
||||
);
|
||||
|
||||
let solid = p.add_item("Solid", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.5, 0.0], width: 64, height: 48, pixel_aspect: 2.0 }));
|
||||
let mut on_solid = layer.clone();
|
||||
on_solid.source = LayerSource::Solid { item: solid };
|
||||
let text = crate::analysis::frames_key::signature_text(p, COMP, &on_solid);
|
||||
assert_eq!(text.split(";in=").next().unwrap(), format!("frames/1;solid={};solid;color=[1.0, 0.5, 0.0];w=64;h=48;par=2.0", solid.0));
|
||||
|
||||
// Every mask mode and motion blur setting gives its own Input Key.
|
||||
let mask = layer.masks().unwrap().groups().next().unwrap().uid;
|
||||
let mut keys = std::collections::BTreeSet::new();
|
||||
for mode in MaskMode::ALL {
|
||||
for blur in [MaskMotionBlur::SameAsLayer, MaskMotionBlur::On, MaskMotionBlur::Off] {
|
||||
let mut l = layer.clone();
|
||||
if let GroupKind::Mask { mode: m, motion_blur, .. } = &mut l.props.find_group_mut(mask).unwrap().kind {
|
||||
(*m, *motion_blur) = (mode, blur);
|
||||
}
|
||||
keys.insert(crate::analysis::frames_key::input_key(p, COMP, &l, ROTO));
|
||||
}
|
||||
}
|
||||
assert_eq!(keys.len(), MaskMode::ALL.len() * 3);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schema":2,"settings":{"bit_depth":"Bpc8","linearize":false,"working_space":null,"blend_linear":false,"time_display":"Timecode","frame_start":0,"audio_sample_rate":48000,"gpu_acceleration":true,"color_engine":"Adobe","hdr":"Clip","output_space":null,"working_gamma":2.2},"items":{"1":{"id":1,"name":"effectcraft-3456-shake.mp4","label":"Aqua","comment":"","parent":null,"kind":{"type":"Footage","path":"/tmp/effectcraft-3456-shake.mp4","kind":"Video","width":160,"height":120,"pixel_aspect":1.0,"frame_rate":{"num":12,"den":1},"native_rate":null,"duration":254016000000,"has_video":true,"has_audio":false,"alpha":"Ignore","premul_color":[0.0,0.0,0.0],"loop_count":1,"codec":"H.264 (High)","missing":false,"color_profile":"Rec709"}},"2":{"id":2,"name":"effectcraft-3456-shake","label":"Sandstone","comment":"","parent":null,"kind":{"type":"Comp","width":160,"height":120,"pixel_aspect":1.0,"frame_rate":{"num":12,"den":1},"duration":254016000000,"display_start":0,"background":[0.0,0.0,0.0],"work_area":[0,254016000000],"layers":[{"id":3,"name":"effectcraft-3456-shake.mp4","source":{"type":"Footage","item":1},"label":"Aqua","comment":"","start_time":0,"in_point":0,"out_point":254016000000,"stretch":100.0,"switches":{"video":true,"audio":true,"solo":false,"locked":false,"shy":false,"collapse":false,"quality":"Best","sampling":"Bilinear","effects":true,"frame_blend":"Off","motion_blur":false,"adjustment":false,"three_d":false,"guide":false},"blend_mode":"Normal","preserve_transparency":false,"track_matte":null,"parent":null,"markers":[],"auto_orient":"Off","props":{"uid":4,"match":"layer","name":"effectcraft-3456-shake.mp4","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":76,"match":"timeRemap","name":"Time Remap","value":{"t":"Scalar","v":0.0},"keys":[{"time":0,"value":{"t":"Scalar","v":0.2},"in_interp":"Linear","out_interp":"Linear","in_ease":[],"out_ease":[],"continuous":false,"auto_bezier":false,"spatial_in":[0.0,0.0,0.0],"spatial_out":[0.0,0.0,0.0],"spatial_auto":true,"spatial_continuous":false,"roving":false},{"time":254016000000,"value":{"t":"Scalar","v":1.0},"in_interp":"Linear","out_interp":"Linear","in_ease":[],"out_ease":[],"continuous":false,"auto_bezier":false,"spatial_in":[0.0,0.0,0.0],"spatial_out":[0.0,0.0,0.0],"spatial_auto":true,"spatial_continuous":false,"roving":false}],"ui":{"kind":"Number"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":5,"match":"masks","name":"Masks","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Group","uid":77,"match":"mask","name":"Mask 1","kind":{"kind":"Mask","mode":"Add","inverted":false,"color":[177,180,79],"locked":false,"motion_blur":"SameAsLayer","feather_falloff":"Smooth","roto_bezier":false},"enabled":true,"children":[{"node":"Prop","uid":78,"match":"path","name":"Mask Path","value":{"t":"Path","v":{"vertices":[[10.0,10.0],[120.0,10.0],[120.0,100.0],[10.0,100.0]],"in_tangents":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"out_tangents":[[0.0,0.0],[0.0,0.0],[0.0,0.0],[0.0,0.0]],"closed":false}},"ui":{"kind":"Path"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":79,"match":"feather","name":"Mask Feather","value":{"t":"Vec2","v":[0.0,0.0]},"ui":{"kind":"Pixels"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":80,"match":"opacity","name":"Mask Opacity","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":81,"match":"expansion","name":"Mask Expansion","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Pixels"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Group","uid":6,"match":"effects","name":"Effects","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Group","uid":26,"match":"ec.distort.warpstabilizer","name":"Warp Stabilizer","kind":{"kind":"Effect","effect":"ec.distort.warpstabilizer"},"enabled":true,"children":[{"node":"Group","uid":28,"match":"stabilization","name":"Stabilization","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":27,"match":"result","name":"Result","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Smooth Motion","No Motion"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":29,"match":"smoothness","name":"Smoothness","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":100.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":30,"match":"mLine truncated
|
||||
@@ -0,0 +1,8 @@
|
||||
asset: analysed-0.24.ecproj
|
||||
title: The tracked-0.22 clip with Time Remap, a mask, Gaussian Blur and Roto Brush, keyed by EffectCraft 0.24.1
|
||||
author: arnav-hiwarkar
|
||||
source: original work
|
||||
license: MIT OR Apache-2.0
|
||||
license-file: ../../../../../LICENSE-MIT
|
||||
added: 2026-11-12 by arnav-hiwarkar
|
||||
notes: Made from tracked-0.22.ecproj with 0.24.1 engine commands (enable Time Remap, a rectangle mask, Gaussian Blur and Roto Brush), its saved analyses replaced by short stand-in strings, and the Warp Stabilizer, 3D Camera Tracker and Roto Brush keys that 0.24.1 computed for it. Written as compact JSON. No media is needed: the tests only check that the keys are kept or cleared.
|
||||
@@ -50,7 +50,7 @@ fn analyses_saved_by_0_22_survive_edits_that_keep_their_frames() {
|
||||
for (len, _, key) in &before {
|
||||
assert!(*len > 0);
|
||||
// The key predates Footage::placed_page, so it isn't today's signature.
|
||||
assert_ne!(key, &crate::warp::signature(&s.project, COMP, layer));
|
||||
assert_ne!(key, &crate::analysis::frames_key::signature(&s.project, COMP, layer));
|
||||
}
|
||||
|
||||
// Adding to the render queue edits the project, as `effectcraft-cli render` does.
|
||||
@@ -122,6 +122,6 @@ fn footage_signature_text_is_pinned() {
|
||||
..Footage::default()
|
||||
};
|
||||
let fields = r#"path: "clip.mp4", kind: Video, width: 160, height: 120, pixel_aspect: 1.0, frame_rate: FrameRate { num: 30, den: 1 }, native_rate: None, duration: Tick(0), has_video: false, has_audio: false, alpha: Straight, premul_color: [0.0, 0.0, 0.0], loop_count: 1, codec: "", missing: false, sequence: [], color_profile: None, layer: None, fields: Off, invert_alpha: true, linear_light: false, preserve_rgb: false, cineon: CineonSettings { log_conversion: false, converted_black: 0.0, converted_white: 1023.0, black: 95.0, white: 685.0, gamma: 1.7, rolloff: 20.0, units: TenBit }, data: None, page: 2, alphabetical: false, start_timecode: None, file_timecode: None, markers: []"#;
|
||||
assert_eq!(crate::warp::footage_text(&f, true), format!("Footage {{ {fields}, placed_page: true }}"));
|
||||
assert_eq!(crate::warp::footage_text(&f, false), format!("Footage {{ {fields} }}"));
|
||||
assert_eq!(crate::analysis::frames_key::legacy::footage_text(&f, false, true), format!("Footage {{ {fields}, placed_page: true }}"));
|
||||
assert_eq!(crate::analysis::frames_key::legacy::footage_text(&f, false, false), format!("Footage {{ {fields} }}"));
|
||||
}
|
||||
Reference in new issue
Block a user