Merge main into M13.5 long-tail polish

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 00:35:54 -04:00
co-authored by Claude Opus 5.5
51 changed files with 6800 additions and 1737 deletions
+10 -1
View File
@@ -146,7 +146,15 @@ fn card(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, a: &str, b: &s
&mut l,
"ec.generate.grid",
[w, h],
&[("width", Value::Scalar(w)), ("height", Value::Scalar(h)), ("border", Value::Scalar(10.0)), ("opacity", Value::Scalar(35.0))],
&[
("sizeFrom", Value::Enum(2)),
("width", Value::Scalar(w)),
("height", Value::Scalar(h)),
("border", Value::Scalar(10.0)),
("opacity", Value::Scalar(35.0)),
// Normal: the grid over the gradient.
("blendingMode", Value::Enum(1)),
],
);
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3(pos));
@@ -174,6 +182,7 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp {
("border", Value::Scalar(3.0)),
("color", Value::Color(hex("#6F7BB8"))),
("opacity", Value::Scalar(45.0)),
("blendingMode", Value::Enum(1)),
],
);
floor.switches.three_d = true;
+30
View File
@@ -635,6 +635,7 @@ impl Session {
self.roto_job = None;
self.roto_pending.clear();
p.fix_next_id();
upgrade_effects(&mut p);
self.project = Arc::new(p);
self.history = History::default();
self.state = EditorState { snapping: true, view_layout: 1, ..Default::default() };
@@ -648,6 +649,35 @@ impl Session {
}
}
/// Bring effect instances saved by earlier versions up to date with the effect registry
/// (renamed / regrouped / added parameters, reordered popups; see
/// [`effectcraft_effects::migrate`]).
pub fn upgrade_effects(p: &mut Project) {
let mut sizes = std::collections::HashMap::new();
for (cid, c) in p.comps() {
for l in &c.layers {
let (w, h) = effectcraft_render::source_size(p, l);
sizes.insert((*cid, l.id), if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] });
}
}
let mut next = p.next_id;
for (cid, it) in p.items.iter_mut() {
let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };
for l in &mut std::sync::Arc::make_mut(c).layers {
let size = sizes.get(&(*cid, l.id)).copied().unwrap_or([100.0, 100.0]);
let Some(fx) = l.props.sub_mut("effects") else { continue };
for n in &mut fx.children {
let effectcraft_project::Node::Group(g) = n else { continue };
let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { continue };
if let Some(spec) = effectcraft_effects::find(effect) {
effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size);
}
}
}
}
p.next_id = next;
}
#[cfg(test)]
mod rq_tests;
#[cfg(test)]
+10 -5
View File
@@ -352,7 +352,8 @@ fn add_effect(proj: &mut Project, l: &mut Layer, effect: &str, comp: &Comp, para
let mut ids = Ids(&mut proj.next_id);
let mut g = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [comp.width as f64, comp.height as f64]);
for (k, v) in params {
if let Some(p) = g.get_mut(k) {
// Paths reach parameters in twirl-down groups (`master/exposure`).
if let Some(p) = g.prop_mut(k) {
p.value = v.clone();
}
}
@@ -364,9 +365,13 @@ fn add_effect(proj: &mut Project, l: &mut Layer, effect: &str, comp: &Comp, para
fn add_adjustment_effect(proj: &mut Project, l: &mut Layer, adj: &Adjustment, comp: &Comp, warnings: &mut Vec<String>, name: &str) {
match adj {
Adjustment::Invert => add_effect(proj, l, "Invert", comp, &[]),
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
add_effect(proj, l, "Brightness & Contrast", comp, &[("brightness", Value::Scalar(*brightness)), ("contrast", Value::Scalar(*contrast))])
}
Adjustment::BrightnessContrast { brightness, contrast, legacy } => add_effect(
proj,
l,
"Brightness & Contrast",
comp,
&[("brightness", Value::Scalar(*brightness)), ("contrast", Value::Scalar(*contrast)), ("useLegacy", Value::Bool(*legacy))],
),
Adjustment::HueSaturation { hue, saturation, lightness, colorize } => {
let mut p = vec![("hue", Value::Scalar(*hue)), ("saturation", Value::Scalar(*saturation)), ("lightness", Value::Scalar(*lightness))];
if *colorize {
@@ -397,7 +402,7 @@ fn add_adjustment_effect(proj: &mut Project, l: &mut Layer, adj: &Adjustment, co
l,
"Exposure",
comp,
&[("exposure", Value::Scalar(*exposure)), ("offset", Value::Scalar(*offset)), ("gamma", Value::Scalar(*gamma))],
&[("master/exposure", Value::Scalar(*exposure)), ("master/offset", Value::Scalar(*offset)), ("master/gamma", Value::Scalar(*gamma))],
),
Adjustment::Other(k) => warnings.push(format!("{name}: adjustment `{k}` is not converted (layer kept as an empty adjustment layer)")),
}
+105
View File
@@ -152,3 +152,108 @@ fn layer_params_get_a_source_companion() {
assert!(matches!(src.ui, effectcraft_project::ParamUi::Hidden));
}
}
/// Projects saved before parameters were renamed, regrouped, added or had their popups
/// reordered open with their instances brought up to date (`effectcraft_effects::migrate`).
#[test]
fn opening_an_old_project_upgrades_effect_instances() {
use effectcraft_effects::migrate::PARAM_ID_ALIASES;
use effectcraft_project::{Node, ParamUi, PropGroup};
let (mut s, a, _) = comp_with_two_solids();
apply(&mut s, a, "ec.blur.gaussian");
// One instance of every effect with renamed parameter ids.
let mut aliased: Vec<&str> = PARAM_ID_ALIASES.iter().map(|(e, _, _)| *e).collect();
aliased.dedup();
for e in &aliased {
apply(&mut s, a, e);
}
// Rewrite the saved instances the way an older version wrote them.
fn take(g: &mut PropGroup, path: &str) -> Option<effectcraft_project::Property> {
match path.split_once('/') {
None => {
let i = g.children.iter().position(|c| matches!(c, Node::Prop(p) if p.match_id == path))?;
match g.children.remove(i) {
Node::Prop(p) => Some(p),
Node::Group(_) => None,
}
}
Some((first, rest)) => take(g.sub_mut(first)?, rest),
}
}
let mut old = (*s.project).clone();
let cid = s.active_comp_id().unwrap();
{
let comp = std::sync::Arc::make_mut(match &mut old.items.get_mut(&cid).unwrap().kind {
effectcraft_project::ItemKind::Comp(c) => c,
_ => unreachable!(),
});
let l = comp.layers.iter_mut().find(|l| l.id == LayerId(a)).unwrap();
for n in &mut l.props.sub_mut("effects").unwrap().children {
let Node::Group(g) = n else { continue };
if g.match_id == "ec.blur.gaussian" {
// Repeat Edge Pixels did not exist yet, Blurriness had another name, the popup
// listed its options in reverse.
take(g, "repeatEdge").unwrap();
g.get_mut("blurriness").unwrap().name = "Old Blurriness".into();
let d = g.get_mut("dimensions").unwrap();
let ParamUi::Popup { options } = &d.ui else { panic!("dimensions is a popup") };
d.ui = ParamUi::Popup { options: options.iter().rev().cloned().collect() };
d.value = KV::Enum(0);
}
for (e, old_id, new_id) in PARAM_ID_ALIASES {
if g.match_id == *e {
let mut p = take(g, new_id).unwrap_or_else(|| panic!("{e}: no {new_id}"));
// Put it back at its old path (which may be inside a group).
let (path, leaf) = old_id.rsplit_once('/').map(|(p, l)| (Some(p), l)).unwrap_or((None, old_id));
p.match_id = leaf.to_string();
let mut at: &mut PropGroup = g;
for seg in path.into_iter().flat_map(|p| p.split('/')) {
if at.sub(seg).is_none() {
at.children.push(PropGroup::new(1_000_000 + at.children.len() as u64, seg, seg).into());
}
at = at.sub_mut(seg).unwrap();
}
at.children.push(p.into());
}
}
}
}
let dir = std::env::temp_dir().join(format!("ec-upgrade-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("old.ecproj").to_string_lossy().to_string();
std::fs::write(&path, old.to_json()).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": path})).unwrap();
let _ = std::fs::remove_dir_all(dir);
let l = s2.project.comp(cid).unwrap().layer(LayerId(a)).unwrap().clone();
let fx = l.effects().unwrap();
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
let sp = |id: &str| spec.params.iter().find(|p| p.id == id).unwrap();
let blur = fx.groups().find(|g| g.match_id == "ec.blur.gaussian").unwrap();
assert_eq!(blur.get("blurriness").unwrap().name, sp("blurriness").name);
assert!(blur.get("repeatEdge").is_some(), "missing parameter added");
let dims = blur.get("dimensions").unwrap();
let ParamUi::Popup { options } = &sp("dimensions").ui else { panic!() };
assert_eq!(dims.value, KV::Enum(options.len() as u32 - 1), "popup value remapped by label");
assert_eq!(dims.ui, sp("dimensions").ui);
for (e, old_id, new_id) in PARAM_ID_ALIASES {
let g = fx.groups().find(|g| g.match_id == *e).unwrap();
let at = |id: &str| {
let (path, leaf) = id.rsplit_once('/').map(|(p, l)| (Some(p), l)).unwrap_or((None, id));
let mut node = Some(g);
for seg in path.into_iter().flat_map(|p| p.split('/')) {
node = node.and_then(|n| n.sub(seg));
}
node.and_then(|n| n.get(leaf)).is_some()
};
assert!(!at(old_id), "{e}: {old_id} still present");
let (path, leaf) = new_id.rsplit_once('/').map(|(p, l)| (Some(p), l)).unwrap_or((None, new_id));
let mut node = Some(g);
for seg in path.into_iter().flat_map(|p| p.split('/')) {
node = node.and_then(|n| n.sub(seg));
}
assert!(node.and_then(|n| n.get(leaf)).is_some(), "{e}: {old_id} not moved to {new_id}");
}
// The upgraded project renders.
let _ = s2.render(cid, s2.time(), Default::default());
}