Files
effectcraft/crates/effects/src/migrate.rs
T
Brandon ThomasandClaude Opus 5.5 355a6255a2 M9.12: effect parameter fidelity – all categories
Align every effect's display name, category, parameter names, order,
twirl-down groups, popup options, units, defaults and ranges with the
public After Effects effect reference, and implement controls that were
ignored or missing where cheap. One commit because the categories share
the registry, migration tables and GPU kernels (only green states).

Infrastructure
- effects::migrate: saved instances are upgraded on project open
  (Session::replace_project → upgrade_effects): renamed ids
  (PARAM_ID_ALIASES), params moved into twirl-down groups, popup values
  remapped by label (POPUP_LABEL_ALIASES), refreshed names/UI hints,
  added params created with legacy_default values that keep old renders.
- flatten_params also keys nested props by match path (group/param), so
  effects declare grouped params with `group/param` ids; layer-source
  companions sit next to grouped layer params.
- lookup() resolves old effect names (EFFECT_NAME_ALIASES).
- Registry-wide test: unique ids/names, non-empty names, defaults in
  range, popup defaults within options, value kinds match UI.
- docs/effects.md generated from the registry (catalog.rs) with GPU /
  32-bpc badges and per-effect status; a test keeps it current.

Batches
- Blur & Sharpen, Channel, 3D Channel, Perspective, Text, Utility:
  Camera Lens Blur iris/highlight groups, Compound Blur layer, Camera-
  Shake Deblur method/strength, Invert full channel list (CPU HLS/YIQ),
  Set Channels/Set Matte layers, Channel Combiner targets, 3D Channel
  Extract clamp/invert.
- Color Correction: temporal smoothing + scene detect for Auto Color/
  Contrast/Levels and Shadow/Highlight, Brightness & Contrast legacy
  switch, Change to Color tolerance group, Leave Color, Photo Filter
  presets, Exposure channels, Levels clip popups, Colorama phase source,
  Color Link sample list, Lumetri groups (CPU + GPU kernels).
- Distort: Bulge, Displacement Map layer/behaviour, Magnify, Optics
  Compensation resize, Ripple conversion, Transform sampling, Turbulent
  Displace types/pinning/evolution, Wave Warp types/pinning, Twirl and
  Ripple radius fixes.
- Generate: 4-Color Gradient, Checkerboard, Grid, Circle size/feather/
  blending, Advanced Lightning types, Cell Pattern, Radio Waves, Fractal
  palettes, Scribble/Vegas names.
- Keying, Matte, Obsolete: Color Difference Key partials/views, Unmult
  controls, Key Light groups, Refine Soft/Hard Matte, Roto Brush groups,
  Mocha shape moved to Matte, Spill Suppressor accuracy, Basic/Path Text
  groups.
- Noise & Grain, Immersive Video, Audio, Time: Fractal/Turbulent Noise
  full control set (noise3.rs, GPU), grain look groups, Remove Grain,
  VR effects, Compressor/Gate/Distortion controls, Timewarp groups,
  VR Sphere to Plane name.
- Simulation: AE twirl-down groups for all simulations, Foam strength,
  Particle Playground barrel radius and mapper targets.
- Stylize, Transition: Glow looping/dimensions/alpha (CPU + GPU),
  Mosaic averaging, Cartoon groups, Roughen Edges evolution, Texturize
  layer, Gradient Wipe layer, Radial Wipe both, Card Wipe options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:15:06 -04:00

372 lines
20 KiB
Rust

//! 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"),
// 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),
_ => 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) -> &'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).expect("just added");
match rest {
Some(r) => group_at(sub, ids, r),
None => 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();
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 {
Some(path) => group_at(g, ids, path).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 {
Some(path) => group_at(g, ids, path).children.push(src.into()),
None => g.children.push(src.into()),
}
}
}
// 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
}
#[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 { unreachable!() };
{
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));
}
}