diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3aa429a53..f1ea2a95d 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/crates/engine/src/analysis.rs b/crates/engine/src/analysis.rs index fa2b8b4ec..a1adb6dac 100644 --- a/crates/engine/src/analysis.rs +++ b/crates/engine/src/analysis.rs @@ -1,4 +1,5 @@ pub mod camera_track; +pub(crate) mod frames_key; pub mod mask_track; pub mod models; pub mod roto; diff --git a/crates/engine/src/analysis/camera_track.rs b/crates/engine/src/analysis/camera_track.rs index 378f42dd1..43f7e1746 100644 --- a/crates/engine/src/analysis/camera_track.rs +++ b/crates/engine/src/analysis/camera_track.rs @@ -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). diff --git a/crates/engine/src/analysis/frames_key.rs b/crates/engine/src/analysis/frames_key.rs new file mode 100644 index 000000000..248b3b4f5 --- /dev/null +++ b/crates/engine/src/analysis/frames_key.rs @@ -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(&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", + } +} diff --git a/crates/engine/src/analysis/frames_key/legacy.rs b/crates/engine/src/analysis/frames_key/legacy.rs new file mode 100644 index 000000000..882c82d80 --- /dev/null +++ b/crates/engine/src/analysis/frames_key/legacy.rs @@ -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 { + 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)) +} diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index 89d3e1609..cb67005f8 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -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)) { diff --git a/crates/engine/src/analysis/warp.rs b/crates/engine/src/analysis/warp.rs index 20b88d45d..610e994ca 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -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, } -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 { - 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() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 054ece456..8c36967ad 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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; diff --git a/crates/engine/src/tests/analysis_signature.rs b/crates/engine/src/tests/analysis_signature.rs new file mode 100644 index 000000000..045e279de --- /dev/null +++ b/crates/engine/src/tests/analysis_signature.rs @@ -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); +} diff --git a/crates/engine/src/tests/fixtures/analysed-0.24.ecproj b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj new file mode 100644 index 000000000..e99b4acc0 --- /dev/null +++ b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj @@ -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":"method","name":"Method","value":{"t":"Enum","v":3},"ui":{"kind":"Popup","options":["Position","Position, Scale, Rotation","Perspective","Subspace Warp"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":31,"match":"preserveScale","name":"Preserve Scale","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":33,"match":"borders","name":"Borders","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":32,"match":"framing","name":"Framing","value":{"t":"Enum","v":2},"ui":{"kind":"Popup","options":["Stabilize Only","Stabilize, Crop","Stabilize, Crop, Auto-scale","Stabilize, Synthesize Edges"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":35,"match":"autoScale","name":"Auto-scale","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":34,"match":"maximumScale","name":"Maximum Scale","value":{"t":"Scalar","v":150.0},"ui":{"kind":"Slider","min":100.0,"max":1000.0,"slider_min":100.0,"slider_max":200.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":36,"match":"actionSafeMargin","name":"Action-safe Margin","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":45.0,"slider_min":0.0,"slider_max":20.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":37,"match":"additionalScale","name":"Additional Scale","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":1.0,"max":1000.0,"slider_min":50.0,"slider_max":150.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":39,"match":"advanced","name":"Advanced","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":38,"match":"detailedAnalysis","name":"Detailed Analysis","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":40,"match":"rollingShutterRipple","name":"Rolling Shutter Ripple","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Automatic Reduction","Enhanced Reduction"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":41,"match":"cropLessSmoothMore","name":"Crop Less <-> Smooth More","value":{"t":"Scalar","v":50.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":42,"match":"synthesizeInputRange","name":"Synthesis Input Range (seconds)","value":{"t":"Scalar","v":1.0},"ui":{"kind":"Slider","min":0.0,"max":10.0,"slider_min":0.0,"slider_max":5.0,"decimals":2},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":43,"match":"synthesizeEdgeFeather","name":"Synthesis Edge Feather","value":{"t":"Scalar","v":0.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":"Group","uid":45,"match":"edgeCropping","name":"Synthesis Edge Cropping","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":44,"match":"left","name":"Left","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.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":46,"match":"top","name":"Top","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.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":47,"match":"right","name":"Right","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.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":48,"match":"bottom","name":"Bottom","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":50.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":49,"match":"hideWarningBanner","name":"Hide Warning Banner","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":50,"match":"showTrackPoints","name":"Show Track Points","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":51,"match":"trackPointSize","name":"Track Point Size","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":200.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":52,"match":"analysis","name":"Analysis","value":{"t":"Str","v":"saved analysis"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":53,"match":"analysisKey","name":"Analysis Key","value":{"t":"Str","v":"03efb7c4b0f27b65"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":54,"match":"ec.perspective.cameratracker","name":"3D Camera Tracker","kind":{"kind":"Effect","effect":"ec.perspective.cameratracker"},"enabled":true,"children":[{"node":"Prop","uid":55,"match":"shotType","name":"Shot Type","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Fixed Angle of View","Variable Zoom","Specify Angle of View"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":56,"match":"horizontalAngleOfView","name":"Horizontal Angle of View","value":{"t":"Scalar","v":40.0},"ui":{"kind":"Slider","min":1.0,"max":170.0,"slider_min":10.0,"slider_max":120.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":57,"match":"showTrackPoints","name":"Show Track Points","value":{"t":"Enum","v":1},"ui":{"kind":"Popup","options":["2D Source","3D Solved"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":58,"match":"renderTrackPoints","name":"Render Track Points","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":59,"match":"trackPointSize","name":"Track Point Size","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":0.0,"max":1000.0,"slider_min":0.0,"slider_max":300.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":60,"match":"targetSize","name":"Target Size","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Slider","min":1.0,"max":1000.0,"slider_min":10.0,"slider_max":300.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":62,"match":"advanced","name":"Advanced","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":61,"match":"solveMethod","name":"Solve Method","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Auto Detect","Typical","Mostly Flat Scene","Tripod Pan"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":63,"match":"methodUsed","name":"Method Used","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":64,"match":"averageError","name":"Average Error","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":65,"match":"detailedAnalysis","name":"Detailed Analysis","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":66,"match":"lensDistortion","name":"Solve Lens Distortion","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":67,"match":"undistort","name":"Undistort Footage","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":68,"match":"autoDeletePoints","name":"Auto-delete Points Across Time","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":69,"match":"hideWarningBanner","name":"Hide Warning Banner","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":70,"match":"tracks","name":"Tracks","value":{"t":"Str","v":"saved tracks"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":71,"match":"tracksKey","name":"Tracks Key","value":{"t":"Str","v":"03efb7c4b0f27b65"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":72,"match":"solve","name":"Solve","value":{"t":"Str","v":"saved solve"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":73,"match":"solveKey","name":"Solve Key","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":74,"match":"deleted","name":"Deleted Points","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":75,"match":"camera","name":"Camera Layer","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":82,"match":"ec.blur.gaussian","name":"Gaussian Blur","kind":{"kind":"Effect","effect":"ec.blur.gaussian"},"enabled":true,"children":[{"node":"Prop","uid":83,"match":"blurriness","name":"Blurriness","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":3000.0,"slider_min":0.0,"slider_max":50.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":84,"match":"dimensions","name":"Blur Dimensions","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Horizontal and Vertical","Horizontal","Vertical"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":85,"match":"repeatEdge","name":"Repeat Edge Pixels","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":86,"match":"ec.matte.rotobrush","name":"Roto Brush & Refine Edge","kind":{"kind":"Effect","effect":"ec.matte.rotobrush"},"enabled":true,"children":[{"node":"Prop","uid":87,"match":"version","name":"Version","value":{"t":"Enum","v":2},"ui":{"kind":"Popup","options":["1.0","2.0","3.0"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":88,"match":"quality","name":"Quality","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Standard","Best"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":90,"match":"rotoBrushPropagation","name":"Roto Brush Propagation","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":89,"match":"searchRadius","name":"Search Radius","value":{"t":"Scalar","v":15.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":50.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":91,"match":"motionThreshold","name":"Motion Threshold","value":{"t":"Scalar","v":10.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":92,"match":"motionDamping","name":"Motion Damping","value":{"t":"Scalar","v":20.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":93,"match":"viewSearchRegion","name":"View Search Region","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":95,"match":"rotoBrushMatte","name":"Roto Brush Matte","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":94,"match":"feather","name":"Feather","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":20.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":96,"match":"contrast","name":"Contrast","value":{"t":"Scalar","v":0.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":97,"match":"shiftEdge","name":"Shift Edge","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":-100.0,"max":100.0,"slider_min":-100.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":98,"match":"reduceChatter","name":"Reduce Chatter","value":{"t":"Scalar","v":0.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":99,"match":"object","name":"Show Object","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":32.0,"slider_min":0.0,"slider_max":32.0,"decimals":0},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Group","uid":101,"match":"refineEdgeMatte","name":"Refine Edge Matte","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":100,"match":"smooth","name":"Smooth","value":{"t":"Scalar","v":1.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":10.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":102,"match":"feather","name":"Feather","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":20.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":103,"match":"contrast","name":"Contrast","value":{"t":"Scalar","v":0.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":104,"match":"shiftEdge","name":"Shift Edge","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":-100.0,"max":100.0,"slider_min":-100.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":105,"match":"reduceChatter","name":"Reduce Chatter","value":{"t":"Scalar","v":0.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":106,"match":"useMotionBlur","name":"Use Motion Blur","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":108,"match":"motionBlur","name":"Motion Blur","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":107,"match":"motionBlurSamples","name":"Samples","value":{"t":"Scalar","v":11.0},"ui":{"kind":"Slider","min":1.0,"max":64.0,"slider_min":1.0,"slider_max":32.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":109,"match":"shutterAngle","name":"Shutter Angle","value":{"t":"Scalar","v":180.0},"ui":{"kind":"Slider","min":0.0,"max":720.0,"slider_min":0.0,"slider_max":360.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":110,"match":"higherQuality","name":"Higher Quality","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]},{"node":"Prop","uid":111,"match":"decontaminateEdgeColors","name":"Decontaminate Edge Colors","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Group","uid":113,"match":"decontamination","name":"Decontamination","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":112,"match":"decontaminationAmount","name":"Decontamination Amount","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":114,"match":"extendWhereSmoothed","name":"Extend Where Smoothed","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":115,"match":"increaseDecontaminationRadius","name":"Increase Decontamination Radius","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Slider","min":0.0,"max":100.0,"slider_min":0.0,"slider_max":20.0,"decimals":1},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":116,"match":"viewDecontaminationMap","name":"View Decontamination Map","value":{"t":"Bool","v":false},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Prop","uid":117,"match":"strokes","name":"Strokes","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":118,"match":"inputKey","name":"Input Key","value":{"t":"Str","v":"7b319ce93027617c"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":119,"match":"frozen","name":"Frozen","value":{"t":"Str","v":"saved frozen"},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":120,"match":"tracked","name":"Tracked","value":{"t":"Str","v":""},"ui":{"kind":"Hidden"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]},{"node":"Group","uid":15,"match":"transform","name":"Transform","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":7,"match":"anchor","name":"Anchor Point","value":{"t":"Vec3","v":[80.0,60.0,0.0]},"ui":{"kind":"Point"},"spatial":true,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":2,"static_only":false},{"node":"Prop","uid":8,"match":"position","name":"Position","value":{"t":"Vec3","v":[80.0,60.0,0.0]},"ui":{"kind":"Point"},"spatial":true,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":2,"static_only":false},{"node":"Prop","uid":9,"match":"scale","name":"Scale","value":{"t":"Vec3","v":[100.0,100.0,100.0]},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":2,"static_only":false},{"node":"Prop","uid":10,"match":"orientation","name":"Orientation","value":{"t":"Vec3","v":[0.0,0.0,0.0]},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":true,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":11,"match":"rotationX","name":"X Rotation","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":true,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":12,"match":"rotationY","name":"Y Rotation","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":true,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":13,"match":"rotation","name":"Rotation","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":14,"match":"opacity","name":"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":"Group","uid":16,"match":"materialOptions","name":"Material Options","kind":{"kind":"Plain"},"enabled":true,"children":[{"node":"Prop","uid":17,"match":"castsShadows","name":"Casts Shadows","value":{"t":"Enum","v":0},"ui":{"kind":"Popup","options":["Off","On","Only"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":18,"match":"lightTransmission","name":"Light Transmission","value":{"t":"Scalar","v":0.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":19,"match":"acceptsShadows","name":"Accepts Shadows","value":{"t":"Enum","v":1},"ui":{"kind":"Popup","options":["Off","On","Only"]},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":20,"match":"acceptsLights","name":"Accepts Lights","value":{"t":"Bool","v":true},"ui":{"kind":"Checkbox"},"spatial":false,"hold_only":true,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":21,"match":"ambient","name":"Ambient","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":22,"match":"diffuse","name":"Diffuse","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":23,"match":"specularIntensity","name":"Specular Intensity","value":{"t":"Scalar","v":50.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":24,"match":"specularShininess","name":"Specular Shininess","value":{"t":"Scalar","v":5.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},{"node":"Prop","uid":25,"match":"metal","name":"Metal","value":{"t":"Scalar","v":100.0},"ui":{"kind":"Percent"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}]}]}}],"markers":[],"shutter_angle":180.0,"shutter_phase":-90.0,"motion_blur_samples":16,"motion_blur_adaptive_limit":128,"renderer":"Classic3D","hide_shy":false,"enable_motion_blur":true,"enable_frame_blending":true,"draft_3d":false,"preserve_frame_rate":false,"preserve_resolution":false,"poster_time":0,"global_light":{"angle":{"uid":0,"match":"globalLightAngle","name":"Global Light Angle","value":{"t":"Scalar","v":120.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false},"altitude":{"uid":0,"match":"globalLightAltitude","name":"Global Light Altitude","value":{"t":"Scalar","v":30.0},"ui":{"kind":"Angle"},"spatial":false,"hold_only":false,"three_d_only":false,"two_d_only":false,"shown_dims":0,"static_only":false}}}}},"next_id":121} \ No newline at end of file diff --git a/crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution new file mode 100644 index 000000000..06e8915f4 --- /dev/null +++ b/crates/engine/src/tests/fixtures/analysed-0.24.ecproj.attribution @@ -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. diff --git a/crates/engine/src/tests/legacy_analysis_keys.rs b/crates/engine/src/tests/legacy_analysis_keys.rs index 06a05cc77..6cdb125d3 100644 --- a/crates/engine/src/tests/legacy_analysis_keys.rs +++ b/crates/engine/src/tests/legacy_analysis_keys.rs @@ -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} }}")); }