effectcraft/crates/effects/src/migrate.rs

703 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Bringing effect instances saved by earlier versions up to date with the registry.
//!
//! Effect instances are ordinary property groups saved with their parameters' display names
//! and UI hints, so a project written before a parameter was renamed, regrouped, added or had
//! its popup reordered still carries the old shape. [`upgrade_instance`] (run when a project is
//! opened) refreshes each instance from its [`EffectSpec`]:
//!
//! - parameters whose id changed are found through [`PARAM_ID_ALIASES`] and renamed;
//! - parameters moved into a twirl-down group (`group/param` ids) are moved there;
//! - display names and UI hints (ranges, popup options) are refreshed, and popup values are
//! remapped **by label** when the option list changed order ([`POPUP_LABEL_ALIASES`] covers
//! renamed labels);
//! - parameters added since are created with their defaults.
//!
//! Animation, expressions and values are kept.
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{Node, ParamUi, PropGroup, Property};
use crate::{EffectSpec, ParamSpec, default_value, group_name, layer_source_id};
/// Parameter ids renamed since they were first saved: `(effect id, old id, new id)`. The new id
/// is a spec id (it may include twirl-down group segments, `group/param`).
pub const PARAM_ID_ALIASES: &[(&str, &str, &str)] = &[
// Color Correction: Change to Color's single Tolerance became the Tolerance group's Hue.
("ec.color.changetocolor", "tolerance", "toleranceGroup/hue"),
// Roto Brush & Refine Edge: propagation controls got their own twirl-down; motion blur and
// decontamination settings are nested under Refine Edge Matte.
("ec.matte.rotobrush", "rotoBrushMatte/searchRadius", "rotoBrushPropagation/searchRadius"),
("ec.matte.rotobrush", "rotoBrushMatte/motionThreshold", "rotoBrushPropagation/motionThreshold"),
("ec.matte.rotobrush", "rotoBrushMatte/motionDamping", "rotoBrushPropagation/motionDamping"),
("ec.matte.rotobrush", "rotoBrushMatte/viewSearchRegion", "rotoBrushPropagation/viewSearchRegion"),
("ec.matte.rotobrush", "refineEdgeMatte/motionBlurSamples", "refineEdgeMatte/motionBlur/motionBlurSamples"),
("ec.matte.rotobrush", "refineEdgeMatte/shutterAngle", "refineEdgeMatte/motionBlur/shutterAngle"),
("ec.matte.rotobrush", "refineEdgeMatte/higherQuality", "refineEdgeMatte/motionBlur/higherQuality"),
("ec.matte.rotobrush", "refineEdgeMatte/decontaminationAmount", "refineEdgeMatte/decontamination/decontaminationAmount"),
("ec.matte.rotobrush", "refineEdgeMatte/extendWhereSmoothed", "refineEdgeMatte/decontamination/extendWhereSmoothed"),
("ec.matte.rotobrush", "refineEdgeMatte/increaseDecontaminationRadius", "refineEdgeMatte/decontamination/increaseDecontaminationRadius"),
("ec.matte.rotobrush", "refineEdgeMatte/viewDecontaminationMap", "refineEdgeMatte/decontamination/viewDecontaminationMap"),
// Keying: Inner/Outer Key's single additional masks became Additional Foreground /
// Background 1 (of 10).
("ec.key.innerouter", "additionalForeground", "additionalForeground/foreground1"),
("ec.key.innerouter", "additionalBackground", "additionalBackground/background1"),
// Generate: Radio Waves' growth speed was called Velocity; Velocity now moves the wave.
("ec.generate.radiowaves", "velocity", "waveMotion/expansion"),
];
/// Popup option labels renamed since they were first saved:
/// `(effect id, param id, old label, new label)`.
pub const POPUP_LABEL_ALIASES: &[(&str, &str, &str, &str)] = &[
// Blur & Sharpen, Channel.
("ec.blur.smart", "mode", "Edge Overlay", "Overlay Edge"),
("ec.channel.minimax", "direction", "Horizontal Only", "Just Horizontal"),
("ec.channel.minimax", "direction", "Vertical Only", "Just Vertical"),
("ec.channel.minimax", "operation", "Minimum then Maximum", "Minimum Then Maximum"),
("ec.channel.minimax", "operation", "Maximum then Minimum", "Maximum Then Minimum"),
("ec.channel.combiner", "to", "Lightness", "Lightness Only"),
("ec.channel.combiner", "to", "Hue", "Hue Only"),
("ec.channel.combiner", "to", "Saturation", "Saturation Only"),
// Distort.
("ec.distort.turbulentdisplace", "pinning", "Pin All Edges", "Pin All"),
("ec.distort.turbulentdisplace", "pinning", "Pin Horizontal Edges", "Pin Horizontal"),
("ec.distort.turbulentdisplace", "pinning", "Pin Vertical Edges", "Pin Vertical"),
("ec.distort.reshape", "elasticity", "Normal", "Absolutely Normal"),
("ec.distort.reshape", "elasticity", "Above Normal", "Above Average"),
("ec.distort.smear", "elasticity", "Normal", "Absolutely Normal"),
("ec.distort.smear", "elasticity", "Above Normal", "Above Average"),
("ec.distort.liquify", "tool", "warp", "Warp"),
("ec.distort.liquify", "tool", "turbulence", "Turbulence"),
("ec.distort.liquify", "tool", "twirlClockwise", "Twirl Clockwise"),
("ec.distort.liquify", "tool", "twirlCounterclockwise", "Twirl Counterclockwise"),
("ec.distort.liquify", "tool", "pucker", "Pucker"),
("ec.distort.liquify", "tool", "bloat", "Bloat"),
("ec.distort.liquify", "tool", "shiftPixels", "Shift Pixels"),
("ec.distort.liquify", "tool", "reflection", "Reflection"),
("ec.distort.liquify", "tool", "clone", "Clone"),
("ec.distort.liquify", "tool", "reconstruction", "Reconstruction"),
("ec.distort.liquify", "tool", "freeze", "Freeze"),
("ec.distort.liquify", "tool", "thaw", "Thaw"),
// Keying, Matte, Obsolete.
("ec.key.colordifference", "view", "Source Only", "Source"),
("ec.key.colordifference", "view", "Matte Only", "Corrected Matte"),
// Simulation.
("ec.sim.carddance", "rowsColumns", "Columns Follow Rows", "Columns Follows Rows"),
("ec.sim.particleplayground", "persistentPropertyMapper/mapRedTo", "X Velocity", "X Speed"),
("ec.sim.particleplayground", "persistentPropertyMapper/mapRedTo", "Y Velocity", "Y Speed"),
("ec.sim.particleplayground", "persistentPropertyMapper/mapGreenTo", "X Velocity", "X Speed"),
("ec.sim.particleplayground", "persistentPropertyMapper/mapGreenTo", "Y Velocity", "Y Speed"),
("ec.sim.particleplayground", "persistentPropertyMapper/mapBlueTo", "X Velocity", "X Speed"),
("ec.sim.particleplayground", "persistentPropertyMapper/mapBlueTo", "Y Velocity", "Y Speed"),
// Generate.
("ec.generate.fractal", "setChoice", "Mandelbrot Over Inverse", "Mandelbrot Over Julia"),
("ec.generate.fractal", "setChoice", "Mandelbrot Inverse Over Mandelbrot", "Mandelbrot Inverse Over Julia"),
("ec.generate.fractal", "fractalColor/palette", "Lightness Bands", "Lightness Gradient"),
("ec.generate.fractal", "fractalColor/palette", "Hue Bands", "Hue Wheel"),
("ec.generate.fractal", "fractalColor/palette", "Hue/Lightness Bands", "Hue Wheel"),
("ec.generate.fractal", "highQualitySettings/samplingMethod", "Edge Detect", "Edge Detect-Fast-May Miss Pixels"),
("ec.generate.fractal", "highQualitySettings/samplingMethod", "Every Pixel", "Brute Force-Slow-Every Pixel"),
("ec.generate.cellpattern", "cellPattern", "Mixed Crystals", "Mixed Crystals HQ"),
("ec.generate.cellpattern", "cellPattern", "Dots", "Bubbles"),
// Noise & Grain, Time.
("ec.noise.matchgrain", "application/blendingMode", "Normal", "Add"),
("ec.time.timedifference", "alphaChannel", "Lightness of Result", "Lightness Of Result"),
("ec.time.timedifference", "alphaChannel", "Max of Result", "Max Of Result"),
// Immersive Video: VR Converter's layouts.
("ec.vr.converter", "sourceProjection", "Equirectangular", "Equirectangular 2:1"),
("ec.vr.converter", "sourceProjection", "Cube-map 3:2", "Cube-map Pano2VR 3:2"),
("ec.vr.converter", "sourceProjection", "Cube-map 6:1", "Cube-map GearVR 6:1"),
("ec.vr.converter", "sourceProjection", "Sphere Map", "Sphere-map"),
("ec.vr.converter", "sourceProjection", "2D Rectilinear", "2D Source"),
("ec.vr.converter", "targetProjection", "Equirectangular", "Equirectangular 2:1"),
("ec.vr.converter", "targetProjection", "Cube-map 3:2", "Cube-map Pano2VR 3:2"),
("ec.vr.converter", "targetProjection", "Cube-map 6:1", "Cube-map GearVR 6:1"),
("ec.vr.converter", "targetProjection", "Sphere Map", "Sphere-map"),
("ec.vr.converter", "targetProjection", "2D Rectilinear", "2D Source"),
];
/// Effect display names used by earlier versions: `(old name, effect id)`. [`crate::lookup`]
/// resolves them so scripts and saved presets naming the old effect still work.
pub const EFFECT_NAME_ALIASES: &[(&str, &str)] = &[
// Keying, Matte, Obsolete.
("mocha shape", "ec.obsolete.mochashape"),
// Immersive Video.
("VR Sphere To Plane", "ec.vr.spheretoplane"),
];
/// The value a parameter added since a project was saved takes in that project when it must
/// differ from the parameter's default to reproduce how the effect rendered before the parameter
/// existed (Brightness & Contrast's Use Legacy, Photo Filter's Custom filter, …).
pub fn legacy_default(effect: &str, param: &str) -> Option<Value> {
Some(match (effect, param) {
("ec.color.brightnesscontrast", "useLegacy") => Value::Bool(true),
// Distort: earlier Ripples were symmetric, Displacement Map kept the layer bounds and
// Magnify sampled smoothly.
("ec.distort.ripple", "conversion") => Value::Enum(1),
("ec.distort.displacementmap", "expandOutput") => Value::Bool(false),
("ec.distort.magnify", "scaling") => Value::Enum(1),
("ec.color.photofilter", "filter") => Value::Enum(crate::color_fx::PHOTO_FILTER_CUSTOM),
("ec.color.colorbalance", "preserveLuminosity") => Value::Bool(false),
("ec.color.leavecolor", "matchColors") => Value::Enum(1),
("ec.color.changetocolor", "change") => Value::Enum(3),
("ec.color.changetocolor", "changeBy") => Value::Enum(1),
("ec.color.changetocolor", "toleranceGroup/lightness" | "toleranceGroup/saturation") => Value::Scalar(100.0),
("ec.color.shadowhighlight", "moreOptions/blackClip" | "moreOptions/whiteClip") => Value::Scalar(0.0),
// Generate: the shape generators used to composite Normal over the layer, with
// independent width and height.
("ec.generate.fourcolor" | "ec.generate.checkerboard" | "ec.generate.grid" | "ec.generate.circle", "blendingMode") => Value::Enum(1),
("ec.generate.checkerboard" | "ec.generate.grid", "sizeFrom") => Value::Enum(2),
// Noise & Grain: Fractal / Turbulent Noise used to clip, halve each octave's weight and
// size, and draw the noise alone; grain was added; Remove Grain filtered channels apart.
("ec.noise.fractal", "overflow") => Value::Enum(0),
("ec.noise.fractal", "subSettings/subInfluence" | "subSettings/subScaling") => Value::Scalar(50.0),
("ec.noise.fractal" | "ec.noise.turbulent", "blendingMode") => Value::Enum(crate::noise3::BLEND_NONE),
("ec.noise.turbulent", "evolutionOptions/turbulenceFactor") => Value::Scalar(0.0),
("ec.noise.addgrain", "application/blendingMode") => Value::Enum(2),
("ec.noise.removegrain", "noiseReductionSettings/mode") => Value::Enum(1),
// Audio: the Compressor's release was always manual.
("ec.audio.compressor", "autoRelease") => Value::Bool(false),
// Keying: Advanced Spill Suppressor's Ultra mode had no hue tolerance.
("ec.key.advancedspill", "ultraSettings/tolerance") => Value::Scalar(100.0),
// Transition: Block Dissolve's blocks had hard edges.
("ec.transition.blockdissolve", "softEdges") => Value::Bool(false),
_ => return None,
})
}
/// Categories whose parameters users edit per instance (Dropdown Menu Control's items, slider
/// ranges): their stored names and UI hints are kept.
const USER_EDITED_CATEGORIES: &[&str] = &["Expression Controls"];
fn take_prop(g: &mut PropGroup, match_id: &str) -> Option<Property> {
let i = g.children.iter().position(|c| matches!(c, Node::Prop(p) if p.match_id == match_id))?;
match g.children.remove(i) {
Node::Prop(p) => Some(p),
Node::Group(_) => None,
}
}
/// Remove the property at spec id `id` (`a/b/param`) from `g`.
fn take_at(g: &mut PropGroup, id: &str) -> Option<Property> {
match id.split_once('/') {
None => take_prop(g, id),
Some((first, rest)) => take_at(g.sub_mut(first)?, rest),
}
}
fn prop_at<'a>(g: &'a mut PropGroup, id: &str) -> Option<&'a mut Property> {
match id.split_once('/') {
None => g.get_mut(id),
Some((first, rest)) => prop_at(g.sub_mut(first)?, rest),
}
}
fn group_at<'a>(g: &'a mut PropGroup, ids: &mut Ids, path: &str) -> Option<&'a mut PropGroup> {
let (first, rest) = match path.split_once('/') {
Some((a, b)) => (a, Some(b)),
None => (path, None),
};
if g.sub(first).is_none() {
g.children.push(ids.group(first, group_name(first)).into());
}
let sub = g.sub_mut(first)?;
match rest {
Some(r) => group_at(sub, ids, r),
None => Some(sub),
}
}
/// Remap one stored popup value from `old` option labels to `new` ones.
fn remap_enum(v: &mut Value, old: &[String], new: &[String], fx: &str, pid: &str) {
let Value::Enum(i) = v else { return };
let Some(label) = old.get(*i as usize) else { return };
let label = POPUP_LABEL_ALIASES.iter().find(|(e, p, o, _)| *e == fx && *p == pid && o == label).map(|(_, _, _, n)| *n).unwrap_or(label);
if let Some(j) = new.iter().position(|n| n == label) {
*i = j as u32;
}
}
/// Refresh a stored property from its spec: name, UI hints, popup values by label.
fn refresh(pr: &mut Property, ps: &ParamSpec, spec: &EffectSpec) {
if USER_EDITED_CATEGORIES.contains(&spec.category) {
return;
}
pr.name = ps.name.to_string();
match (&pr.ui, &ps.ui) {
(ParamUi::Popup { options: old }, ParamUi::Popup { options: new }) => {
if old != new {
let old = old.clone();
remap_enum(&mut pr.value, &old, new, spec.id, ps.id);
for k in &mut pr.keys {
remap_enum(&mut k.value, &old, new, spec.id, ps.id);
}
}
pr.ui = ps.ui.clone();
}
// A parameter that became internal (superseded by a new one) is hidden.
(_, ParamUi::Hidden) => pr.ui = ParamUi::Hidden,
// Same kind of value: take the spec's hints (units, ranges).
(a, b) if std::mem::discriminant(a) == std::mem::discriminant(b) || is_numeric_ui(a) && is_numeric_ui(b) => pr.ui = ps.ui.clone(),
_ => {}
}
}
fn is_numeric_ui(u: &ParamUi) -> bool {
matches!(u, ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Percent | ParamUi::Angle | ParamUi::Pixels)
}
/// Bring the saved instance `g` of `spec` up to date (see the module docs). `layer_size` sizes
/// point defaults of added parameters. Returns whether anything changed.
pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> bool {
let before = g.clone();
// Card Wipe's Back Layer was a None / Self popup; it is a layer parameter now, with "Self"
// kept in the hidden Back Layer Is Self switch.
let mut back_self = None;
if spec.id == "ec.transition.cardwipe"
&& let Some(pr) = g.get_mut("backLayer")
&& matches!(pr.ui, ParamUi::Popup { .. })
{
back_self = Some(pr.value.as_enum() == 1);
pr.value = Value::Layer(None);
pr.keys.clear();
pr.ui = ParamUi::Layer;
}
for ps in &spec.params {
// Locate the stored property: at its spec path, under an old id, or at top level
// before it moved into a twirl-down group.
let found = prop_at(g, ps.id).is_some();
if !found {
let moved = PARAM_ID_ALIASES.iter().filter(|(e, _, new)| *e == spec.id && *new == ps.id).find_map(|(_, old, _)| take_at(g, old)).or_else(|| {
let leaf = ps.id.rsplit('/').next().unwrap_or(ps.id);
// Not when the top-level leaf is an old id of another parameter (Radio
// Waves' old `velocity` is the new `waveMotion/expansion`).
let aliased = PARAM_ID_ALIASES.iter().any(|(e, old, _)| *e == spec.id && *old == leaf);
(leaf != ps.id && !aliased && !spec.params.iter().any(|q| q.id == leaf)).then(|| take_prop(g, leaf)).flatten()
});
let (path, leaf) = ps.id.rsplit_once('/').map(|(p, l)| (Some(p), l)).unwrap_or((None, ps.id));
let pr = match moved {
Some(mut pr) => {
pr.match_id = leaf.to_string();
pr
}
None => {
let v = legacy_default(spec.id, ps.id).unwrap_or_else(|| default_value(ps, layer_size));
let mut pr = Property::new(ids.alloc(), leaf, ps.name, v).with_ui(ps.ui.clone());
pr.spatial = matches!(ps.ui, ParamUi::Point);
pr.hold_only |= matches!(ps.ui, ParamUi::Checkbox | ParamUi::Popup { .. } | ParamUi::Layer);
pr
}
};
match path.and_then(|path| group_at(g, ids, path)) {
Some(sub) => sub.children.push(pr.into()),
None => g.children.push(pr.into()),
}
}
if let Some(pr) = prop_at(g, ps.id) {
refresh(pr, ps, spec);
}
// Layer parameters' source companions.
let sid = layer_source_id(ps.id);
if matches!(ps.ui, ParamUi::Layer) && !spec.params.iter().any(|q| q.id == sid) && prop_at(g, &sid).is_none() {
let (path, leaf) = ps.id.rsplit_once('/').map(|(p, l)| (Some(p), l)).unwrap_or((None, ps.id));
// Companions of grouped layer parameters were once stored at top level.
let src = take_prop(g, &sid).map(|mut p| {
p.match_id = layer_source_id(leaf);
p
});
let src = src.unwrap_or_else(|| {
let mut src = Property::new(ids.alloc(), &layer_source_id(leaf), &format!("{} Source", ps.name), Value::Enum(2)).with_ui(ParamUi::Hidden);
src.hold_only = true;
src
});
match path.and_then(|path| group_at(g, ids, path)) {
Some(sub) => sub.children.push(src.into()),
None => g.children.push(src.into()),
}
}
}
if let Some(on) = back_self
&& let Some(pr) = g.get_mut("backSelf")
{
pr.value = Value::Bool(on);
}
// Refresh twirl-down group names.
fn names(g: &mut PropGroup) {
for c in &mut g.children {
if let Node::Group(sg) = c {
let n = group_name(&sg.match_id);
if n != sg.match_id {
sg.name = n.to_string();
}
names(sg);
}
}
}
if !USER_EDITED_CATEGORIES.contains(&spec.category) && spec.params.iter().any(|p| p.id.contains('/')) {
names(g);
}
*g != before
}
/// Bring an effect instance saved before project schema 2 into effect space (#227).
///
/// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on
/// their origin. Effects used to measure positions from the origin, so the default "layer
/// centre" sat at the comp's bottom-right corner; they now measure from the bounds' top-left,
/// as After Effects does. `d` is the bounds' half size: positions the instance holds move by it
/// so they keep their place in the comp (point parameters with their keyframes, Paint stroke
/// paths and clone positions, Puppet mesh seeds and pin rest points, Liquify and Roto Brush
/// strokes). A point parameter still at its default (`layer_size`) and not animated stays: it
/// meant the layer centre and now is. Expression controls keep their values (what they mean is
/// up to the expressions reading them), and expressions are not rewritten.
///
/// `clone_d(source)`: the shift of a Paint clone stroke's source layer (`None` = this layer),
/// whose effect space its Clone Position is in.
pub fn to_effect_space(spec: &EffectSpec, g: &mut PropGroup, layer_size: [f64; 2], d: [f64; 2], clone_d: &dyn Fn(Option<u64>) -> [f64; 2]) {
if spec.id.starts_with("ec.control.") {
return;
}
let defaults: Vec<u64> = spec
.params
.iter()
.filter(|ps| matches!(ps.ui, ParamUi::Point | ParamUi::Point3))
.filter_map(|ps| g.prop(ps.id).filter(|pr| pr.keys.is_empty() && same_point(&pr.value, &default_value(ps, layer_size))).map(|pr| pr.uid))
.collect();
let at = Shift { d, clone_d, defaults: &defaults, effect: spec.id };
at.group(g);
}
/// What [`to_effect_space`] moves, and by how much.
struct Shift<'a> {
d: [f64; 2],
clone_d: &'a dyn Fn(Option<u64>) -> [f64; 2],
/// Point parameters left at their defaults.
defaults: &'a [u64],
effect: &'a str,
}
impl Shift<'_> {
fn group(&self, g: &mut PropGroup) {
// A Paint clone stroke's Clone Position is in its source layer's effect space.
let clone_d = g.get("clone_source").map(|s| (self.clone_d)(s.value.as_layer())).unwrap_or(self.d);
for n in &mut g.children {
match n {
Node::Group(sg) => self.group(sg),
Node::Prop(pr) => {
let d = if pr.match_id == "clone_position" { clone_d } else { self.d };
let kind = match (&pr.ui, pr.match_id.as_str()) {
(ParamUi::Point | ParamUi::Point3, _) if !self.defaults.contains(&pr.uid) => Some(Data::Point),
(ParamUi::Path, _) => Some(Data::Point),
(ParamUi::Hidden, "seed" | "rest") if self.effect == crate::puppet::ID => Some(Data::Point),
(ParamUi::Hidden, "distortionMesh") if self.effect == "ec.distort.liquify" => Some(Data::Liquify),
(ParamUi::Hidden, crate::roto::STROKES) if self.effect == crate::roto::ID => Some(Data::Roto),
_ => None,
};
if let Some(kind) = kind.filter(|_| d != [0.0; 2]) {
shift_value(&mut pr.value, kind, d);
for k in &mut pr.keys {
shift_value(&mut k.value, kind, d);
}
}
}
}
}
}
}
/// How a value holds positions.
#[derive(Clone, Copy)]
enum Data {
/// A point (2D or 3D: x and y move) or a path (its vertices; tangents are relative).
Point,
/// Liquify's Distortion Mesh text (`distort4::LiquifyStroke` lines).
Liquify,
/// Roto Brush's Strokes JSON ([`effectcraft_track::roto::RotoData`]).
Roto,
}
fn shift_value(v: &mut Value, kind: Data, d: [f64; 2]) {
let add = |p: &mut [f64; 2]| *p = [p[0] + d[0], p[1] + d[1]];
match (kind, v) {
(Data::Point, Value::Vec2(p)) => add(p),
(Data::Point, Value::Vec3(p)) => *p = [p[0] + d[0], p[1] + d[1], p[2]],
(Data::Point, Value::Path(sp)) => sp.vertices.iter_mut().for_each(add),
(Data::Liquify, Value::Str(s)) => {
// Lines that don't read as strokes are kept as they are.
let lines: Vec<String> = s
.lines()
.map(|l| match crate::distort4::LiquifyStroke::parse(l) {
Some(mut st) => {
st.points.iter_mut().for_each(add);
st.to_line()
}
None => l.to_string(),
})
.collect();
*s = lines.join("\n");
}
(Data::Roto, Value::Str(s)) => {
// Unreadable data is kept as it is.
if let Ok(mut r) = serde_json::from_str::<effectcraft_track::roto::RotoData>(s) {
r.strokes.iter_mut().flat_map(|st| st.points.iter_mut()).for_each(add);
*s = r.to_json();
}
}
_ => {}
}
}
/// Two point values equal up to float noise.
fn same_point(a: &Value, b: &Value) -> bool {
let (a, b) = (a.components(), b.components());
a.len() == b.len() && a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{find, instantiate};
#[test]
fn upgrade_is_a_no_op_on_fresh_instances() {
let mut next = 1;
for spec in crate::registry() {
let mut g = instantiate(spec, &mut Ids(&mut next), spec.name, [100.0, 50.0]);
assert!(!upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]), "{} changed on upgrade", spec.id);
}
}
#[test]
fn upgrade_adds_missing_params_refreshes_names_and_remaps_popups() {
let spec = find("ec.blur.gaussian").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Gaussian Blur", [100.0, 50.0]);
// An old save: a missing parameter, a stale name, a popup stored with options in
// another order.
let removed = spec.params.last().unwrap().id;
g.children.retain(|c| c.match_id() != removed);
let popup = spec.params.iter().find(|p| matches!(p.ui, ParamUi::Popup { .. })).expect("Gaussian Blur has a popup");
let ParamUi::Popup { options } = &popup.ui else { panic!("not a popup") };
{
let pr = g.get_mut(popup.id).unwrap();
pr.name = "Old Name".into();
let mut rev = options.clone();
rev.reverse();
pr.ui = ParamUi::Popup { options: rev };
pr.value = Value::Enum(0); // = the last option in the current order
}
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert!(g.get(removed).is_some());
let pr = g.get(popup.id).unwrap();
assert_eq!(pr.name, popup.name);
assert_eq!(pr.value, Value::Enum(options.len() as u32 - 1));
assert_eq!(pr.ui, popup.ui);
}
#[test]
fn upgrade_moves_params_into_twirl_down_groups() {
let spec = find(crate::warp_stab::ID).unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Warp Stabilizer", [100.0, 50.0]);
// Pretend Smoothness was saved at top level with a custom value.
let mut pr = take_at(&mut g, "stabilization/smoothness").unwrap();
pr.value = Value::Scalar(12.0);
g.children.push(pr.into());
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert!(g.get("smoothness").is_none());
assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0));
}
#[test]
fn card_wipe_back_layer_popup_becomes_a_layer_parameter() {
let spec = find("ec.transition.cardwipe").unwrap();
let mut next = 1;
for (old, self_) in [(1, true), (0, false)] {
let mut g = instantiate(spec, &mut Ids(&mut next), "Card Wipe", [100.0, 50.0]);
take_prop(&mut g, "backSelf");
let pr = g.get_mut("backLayer").unwrap();
pr.ui = ParamUi::Popup { options: vec!["None".into(), "Self".into()] };
pr.value = Value::Enum(old);
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(g.get("backLayer").unwrap().value, Value::Layer(None));
assert_eq!(g.get("backLayer").unwrap().ui, ParamUi::Layer);
assert_eq!(g.get("backSelf").unwrap().value, Value::Bool(self_));
}
}
#[test]
fn particle_playground_mapper_targets_remap_by_label() {
let spec = find("ec.sim.particleplayground").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Particle Playground", [100.0, 50.0]);
let old: Vec<String> =
["None", "Red", "Green", "Blue", "Kinetic Friction", "Scale", "X", "Y", "X Speed", "Y Speed", "X Force", "Y Force", "Opacity", "Mass"]
.iter()
.map(|s| s.to_string())
.collect();
let pr = prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap();
pr.ui = ParamUi::Popup { options: old };
pr.value = Value::Enum(8);
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(prop_at(&mut g, "persistentPropertyMapper/mapRedTo").unwrap().value, Value::Enum(15), "X Speed");
}
#[test]
fn selective_color_ranges_move_into_details() {
let spec = find("ec.color.selectivecolor").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Selective Color", [100.0, 50.0]);
let mut pr = take_at(&mut g, "details/reds/redsCyan").unwrap();
pr.value = Value::Scalar(40.0);
g.children.push(pr.into());
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert!(g.get("redsCyan").is_none());
assert_eq!(prop_at(&mut g, "details/reds/redsCyan").unwrap().value, Value::Scalar(40.0));
assert_eq!(g.sub("details").unwrap().sub("reds").unwrap().name, "Reds");
}
#[test]
fn inner_outer_key_additional_masks_move_into_their_groups() {
let spec = find("ec.key.innerouter").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Inner/Outer Key", [100.0, 50.0]);
let mut pr = take_at(&mut g, "additionalForeground/foreground1").unwrap();
pr.match_id = "additionalForeground".into();
pr.value = Value::Enum(3);
g.children.push(pr.into());
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert!(g.get("additionalForeground").is_none());
assert_eq!(prop_at(&mut g, "additionalForeground/foreground1").unwrap().value, Value::Enum(3));
}
#[test]
fn match_grain_viewing_modes_remap_by_label() {
let spec = find("ec.noise.matchgrain").unwrap();
let mut next = 1;
let mut g = instantiate(spec, &mut Ids(&mut next), "Match Grain", [100.0, 50.0]);
let pr = g.get_mut("viewingMode").unwrap();
pr.ui = ParamUi::Popup { options: vec!["Final Output".into(), "Noise Samples".into(), "Blending Matte".into()] };
pr.value = Value::Enum(0);
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
assert_eq!(g.get("viewingMode").unwrap().value, Value::Enum(4), "Final Output");
}
/// The shift a 200 × 100 shape layer's effect positions get (#227): half its bounds.
const D: [f64; 2] = [100.0, 50.0];
const SIZE: [f64; 2] = [200.0, 100.0];
fn instance(id: &str, next: &mut u64) -> (&'static EffectSpec, PropGroup) {
let spec = find(id).unwrap();
(spec, instantiate(spec, &mut Ids(next), spec.name, SIZE))
}
fn own(_: Option<u64>) -> [f64; 2] {
D
}
#[test]
fn effect_points_move_into_effect_space_unless_left_at_their_default() {
let mut next = 1;
// Twirl: a set centre (with keyframes) moves; Bulge's default centre stays.
let (spec, mut twirl) = instance("ec.distort.twirl", &mut next);
let c = twirl.get_mut("center").unwrap();
c.value = Value::Vec2([-30.0, -10.0]);
let key = |t: i64, v: Value| {
let mut k = effectcraft_keyframe::Keyframe::new(Default::default(), v);
k.time.0 = t;
k
};
c.keys = vec![key(0, Value::Vec2([0.0, 0.0])), key(1000, Value::Vec2([5.0, -5.0]))];
to_effect_space(spec, &mut twirl, SIZE, D, &own);
let c = twirl.get("center").unwrap();
assert_eq!(c.value, Value::Vec2([70.0, 40.0]));
assert_eq!(c.keys.iter().map(|k| k.value.clone()).collect::<Vec<_>>(), vec![Value::Vec2([100.0, 50.0]), Value::Vec2([105.0, 45.0])]);
let (spec, mut bulge) = instance("ec.distort.bulge", &mut next);
let before = bulge.clone();
to_effect_space(spec, &mut bulge, SIZE, D, &own);
assert_eq!(bulge, before, "the default centre now means the layer centre");
// Expression controls keep their values.
let (spec, mut ctl) = instance("ec.control.point", &mut next);
let pr = ctl.props().find(|p| p.ui == ParamUi::Point).unwrap().uid;
ctl.find_mut(pr).unwrap().value = Value::Vec2([5.0, 5.0]);
to_effect_space(spec, &mut ctl, SIZE, D, &own);
assert_eq!(ctl.find(pr).unwrap().value, Value::Vec2([5.0, 5.0]));
}
#[test]
fn puppet_paint_liquify_and_roto_data_move_into_effect_space() {
let mut next = 1;
// Puppet: mesh seed, pin rest and Position.
let (spec, mut puppet) = instance(crate::puppet::ID, &mut next);
let mut mesh = crate::puppet::mesh_group(&mut Ids(&mut next), "Mesh 1", [1.0, 2.0], &crate::puppet::MeshOpts::default());
let pin = crate::puppet::pin_group(&mut Ids(&mut next), "Puppet Pin 1", crate::puppet::PinKind::Position, [3.0, 4.0]);
mesh.sub_mut("deform").unwrap().children.push(pin.into());
puppet.children.push(mesh.into());
to_effect_space(spec, &mut puppet, SIZE, D, &own);
let mesh = puppet.sub("mesh").unwrap();
assert_eq!(mesh.get("seed").unwrap().value, Value::Vec2([101.0, 52.0]));
let pin = mesh.sub("deform").unwrap().sub("pin").unwrap();
assert_eq!(pin.get("rest").unwrap().value, Value::Vec2([103.0, 54.0]));
assert_eq!(pin.get("position").unwrap().value, Value::Vec2([103.0, 54.0]));
// Paint: the stroke path, its transform, and a clone position in its source layer's space.
let (spec, mut paint) = instance(crate::paint::ID, &mut next);
let s = crate::paint::StrokeSpec {
kind: crate::paint::StrokeKind::Clone,
points: vec![[0.0, 0.0], [10.0, 0.0]],
clone_source: Some(7),
clone_position: [20.0, 20.0],
..Default::default()
};
paint.children.push(crate::paint::stroke_group(&mut Ids(&mut next), "Clone 1", &s).into());
to_effect_space(spec, &mut paint, SIZE, D, &|src| if src == Some(7) { [0.0; 2] } else { D });
let st = paint.sub("clone").unwrap();
let Value::Path(path) = &st.get("path").unwrap().value else { panic!("no path") };
assert_eq!(path.vertices, vec![[100.0, 50.0], [110.0, 50.0]]);
assert_eq!(st.sub("transform").unwrap().get("position").unwrap().value, Value::Vec2([100.0, 50.0]));
assert_eq!(st.sub("stroke_options").unwrap().get("clone_position").unwrap().value, Value::Vec2([20.0, 20.0]), "a footage source");
// Liquify: stroke points move, the clone offset (relative) doesn't; unknown lines stay.
let (spec, mut liquify) = instance("ec.distort.liquify", &mut next);
let line =
crate::distort4::LiquifyStroke { tool: 0, size: 64.0, pressure: 50.0, jitter: 0.0, clone_offset: [3.0, 3.0], points: vec![[1.0, 1.0]] }.to_line();
liquify.get_mut("distortionMesh").unwrap().value = Value::Str(format!(
"{line}
not a stroke"
));
to_effect_space(spec, &mut liquify, SIZE, D, &own);
let Value::Str(text) = &liquify.get("distortionMesh").unwrap().value else { panic!("no mesh") };
let moved = crate::distort4::LiquifyStroke { points: vec![[101.0, 51.0]], ..crate::distort4::LiquifyStroke::parse(&line).unwrap() };
assert_eq!(
text,
&format!(
"{}
not a stroke",
moved.to_line()
)
);
// Roto Brush strokes.
let (spec, mut roto) = instance(crate::roto::ID, &mut next);
let data = effectcraft_track::roto::RotoData {
base: Some(0),
span: [0, 10],
strokes: vec![effectcraft_track::roto::Stroke { kind: effectcraft_track::roto::StrokeKind::Fg, frame: 0, radius: 5.0, points: vec![[2.0, 2.0]] }],
};
roto.get_mut(crate::roto::STROKES).unwrap().value = Value::Str(data.to_json());
to_effect_space(spec, &mut roto, SIZE, D, &own);
let Value::Str(text) = &roto.get(crate::roto::STROKES).unwrap().value else { panic!("no strokes") };
assert_eq!(effectcraft_track::roto::RotoData::from_json(text).strokes[0].points, vec![[102.0, 52.0]]);
}
#[test]
fn layers_with_a_source_rectangle_keep_their_effect_positions() {
let mut next = 1;
let (spec, mut twirl) = instance("ec.distort.twirl", &mut next);
twirl.get_mut("center").unwrap().value = Value::Vec2([3.0, 4.0]);
let before = twirl.clone();
to_effect_space(spec, &mut twirl, SIZE, [0.0; 2], &|_| [0.0; 2]);
assert_eq!(twirl, before);
}
}