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..e093c8aed --- /dev/null +++ b/crates/engine/src/analysis/frames_key.rs @@ -0,0 +1,547 @@ +//! 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. Every struct and enum it writes is +//! 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. The +//! text leaves out observations (whether footage is available) and data that never changes pixels +//! (names, labels, UI hints, markers), so they never drop saved analyses (#3534, #3536). +//! +//! Keys saved by 0.22–0.24 hashed derived `Debug` text instead. [`legacy`] still recognises +//! them, and a matching saved key is kept rather than rewritten: frozen Roto Brush mattes and +//! segmentations hash the saved key itself. + +use std::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, + 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("missing", missing); + 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) { + // The uid, name and colour label identify and label the group; they never change pixels. + let PropGroup { uid: _, match_id, name: _, kind, enabled, label: _, children } = g; + if depth >= MAX_DEPTH { + self.word("tooDeep"); + return; + } + self.str("group", match_id); + 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) { + // The uid, name and UI hints identify and show the property; 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.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..6147883ae --- /dev/null +++ b/crates/engine/src/analysis/frames_key/legacy.rs @@ -0,0 +1,144 @@ +//! 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. 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(2); + out.push(signature_with_source(p, comp, layer, &footage_text(f, f.missing, true))); + if matches!(layer.source, LayerSource::Footage { .. }) && !f.placed_page { + out.push(signature_with_source(p, comp, layer, &footage_text(f, 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..4a79f5aa0 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -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..1985f0ca0 100644 --- a/crates/engine/src/analysis/warp.rs +++ b/crates/engine/src/analysis/warp.rs @@ -17,13 +17,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 +99,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 +125,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 0422e95b4..eddeeb7ef 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; @@ -27,7 +28,6 @@ mod key_metadata_order; mod key_time; mod keyframe_text; mod keylight; -mod analysis_signature; mod legacy_analysis_keys; mod limits; mod lottie; diff --git a/crates/engine/src/tests/analysis_signature.rs b/crates/engine/src/tests/analysis_signature.rs index 7429c4035..98612bee4 100644 --- a/crates/engine/src/tests/analysis_signature.rs +++ b/crates/engine/src/tests/analysis_signature.rs @@ -92,3 +92,71 @@ fn analyses_saved_by_0_24_survive_edits_that_keep_their_frames() { 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)";missing=false;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";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); + // Names and labels only show things. + s.execute_checked("layer.rename", json!({"layer": CLIP.0, "name": "Renamed"})).unwrap(); + s.edit("rename", None, |p, _| { + let l = p.comp_mut(COMP).unwrap().layer_mut(CLIP).unwrap(); + l.props.find_group_mut(82).unwrap().name = "Soft".into(); + 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); + + // 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())); +} 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} }}")); }