Squash of #1060 by @RavoxX, without the docs/images screenshots (kept out of the repo). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9aabb5da4f
commit
1765dc53df
|
|
@ -117,7 +117,7 @@ fn find_fx(s: &Session, p: &Value, cmd: &str) -> Result<(effectcraft_project::It
|
|||
let g = match p.get("effect") {
|
||||
Some(Value::Number(n)) => {
|
||||
let n = n.as_u64().unwrap_or(0);
|
||||
fx.groups().find(|g| g.uid == n).or_else(|| fx.groups().nth(n.saturating_sub(1) as usize))
|
||||
fx.groups().find(|g| g.uid == n).or_else(|| usize::try_from(n.saturating_sub(1)).ok().and_then(|i| fx.groups().nth(i)))
|
||||
}
|
||||
Some(Value::String(name)) => fx.groups().find(|g| &g.name == name || g.match_id == *name),
|
||||
_ => s.state.selected_props.iter().find_map(|(l, u)| (*l == lid).then(|| fx.groups().find(|g| g.uid == *u)).flatten()),
|
||||
|
|
@ -416,6 +416,35 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Tint's Swap Colors button edits both swatches at the CTI in one undo step. Like Reset,
|
||||
/// it keys animated parameters without moving their existing keys or replacing expressions.
|
||||
fn swap_tint_colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "effect.swapTintColors";
|
||||
if !p.is_object() || p.get("effect").is_some_and(|v| !v.is_string() && v.as_u64().is_none_or(|n| n == 0)) {
|
||||
return Err(bad(cmd, "effect must be a positive index, uid or name"));
|
||||
}
|
||||
let (cid, lid, uid) = find_fx(s, p, cmd)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
let g = layer.props.find_group(uid).filter(|g| g.match_id == "ec.color.tint").ok_or_else(|| bad(cmd, "not a Tint effect"))?;
|
||||
let lt = layer.layer_time(s.time_of(cid));
|
||||
let color = |id| {
|
||||
g.prop(id)
|
||||
.map(|pr| pr.value_at(lt))
|
||||
.filter(|v| matches!(v, effectcraft_keyframe::Value::Color(_)))
|
||||
.ok_or_else(|| bad(cmd, "Tint is missing a colour parameter"))
|
||||
};
|
||||
let (black, white) = (color("black")?, color("white")?);
|
||||
s.edit("Swap Tint Colors", None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(cmd, "effect gone"))?;
|
||||
for (id, value) in [("black", white), ("white", black)] {
|
||||
g.prop_mut(id).ok_or_else(|| bad(cmd, "Tint is missing a colour parameter"))?.set_value_at(lt, value);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"effect": uid}))
|
||||
}
|
||||
|
||||
fn last(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = s.state.last_effect.clone().ok_or_else(|| bad("effect.applyLast", "no effect applied yet"))?;
|
||||
let mut q = p.clone();
|
||||
|
|
@ -507,6 +536,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("effect.copy", "Copy Effects", [], None, "{layer?, effect? | effects?: [uid|name|index]} (default: the selected effects)", has_layers, copy),
|
||||
cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste),
|
||||
cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset),
|
||||
cmd!(
|
||||
"effect.swapTintColors",
|
||||
"Swap Tint Colors",
|
||||
[],
|
||||
None,
|
||||
"{comp?, layer?, effect?: index|uid|name} — swaps Tint's Map Black To and Map White To at the current layer time in one undo step",
|
||||
has_layers,
|
||||
swap_tint_colors
|
||||
),
|
||||
crate::query!(
|
||||
"effect.list",
|
||||
"List Effects",
|
||||
|
|
|
|||
|
|
@ -147,6 +147,100 @@ fn reset_keys_animated_params_at_the_cti() {
|
|||
assert_eq!(pr.keys[1].value, KV::Scalar(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_swaps_only_its_colors_in_one_undo_step() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let first = apply(&mut s, a, "Tint");
|
||||
let target = apply(&mut s, a, "Tint");
|
||||
let other = apply(&mut s, b, "Tint");
|
||||
for (path, value) in [("black", json!([1.0, 0.0, 0.0, 1.0])), ("white", json!([0.0, 0.25, 1.0, 1.0])), ("amount", json!(45.0))] {
|
||||
s.execute("prop.set", json!({"layer": a, "path": format!("effects/#2/{path}"), "value": value})).unwrap();
|
||||
}
|
||||
let before_a = layer(&s, a);
|
||||
let before_b = layer(&s, b);
|
||||
let n = s.history.undo.len();
|
||||
s.execute("effect.swapTintColors", json!({"layer": a, "effect": target})).unwrap();
|
||||
let swapped = layer(&s, a);
|
||||
let g = swapped.props.find_group(target).unwrap();
|
||||
assert_eq!(g.prop("black").unwrap().value, KV::Color([0.0, 0.25, 1.0, 1.0]));
|
||||
assert_eq!(g.prop("white").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(g.prop("amount").unwrap().value, KV::Scalar(45.0));
|
||||
assert_eq!(swapped.props.find_group(first), before_a.props.find_group(first));
|
||||
assert_eq!(layer(&s, b).props.find_group(other), before_b.props.find_group(other));
|
||||
assert_eq!(s.history.undo.len(), n + 1);
|
||||
assert!(s.undo());
|
||||
assert_eq!(layer(&s, a).props, before_a.props);
|
||||
assert!(s.redo());
|
||||
assert_eq!(layer(&s, a).props, swapped.props);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_swap_keys_animated_colors_at_layer_time() {
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
let target = apply(&mut s, a, "Tint");
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/black", "value": [1.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/white", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": a, "path": "effects/#1/black"})).unwrap();
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/black", "value": [0.0, 1.0, 0.0, 1.0]})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"layers": [a], "percent": 200.0})).unwrap();
|
||||
// At comp time 2s the stretched layer is at 1s, between its unchanged 0s and 2s keys.
|
||||
let before = layer(&s, a);
|
||||
let g = before.props.find_group(target).unwrap();
|
||||
let lt = before.layer_time(s.time());
|
||||
let expected = g.prop("black").unwrap().value_at(lt);
|
||||
let keys = g.prop("black").unwrap().keys.clone();
|
||||
select_effect(&mut s, a, target);
|
||||
s.execute("effect.swapTintColors", json!({})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
let g = after.props.find_group(target).unwrap();
|
||||
let black = g.prop("black").unwrap();
|
||||
assert_eq!(black.keys.len(), 3);
|
||||
assert_eq!(black.keys[0], keys[0]);
|
||||
assert_eq!(black.keys[2], keys[1]);
|
||||
assert_eq!(black.keys[1].time, lt);
|
||||
assert_eq!(black.keys[1].value, KV::Color([0.0, 0.0, 1.0, 1.0]));
|
||||
assert_eq!(g.prop("white").unwrap().value, expected);
|
||||
assert!(g.prop("white").unwrap().keys.is_empty());
|
||||
assert!(s.undo());
|
||||
assert_eq!(layer(&s, a).props, before.props);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_swap_rejects_invalid_targets_without_editing() {
|
||||
let mut empty = Session::default();
|
||||
assert!(empty.execute("effect.swapTintColors", json!({})).is_err());
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
let blur = apply(&mut s, a, "Gaussian Blur");
|
||||
let tint = apply(&mut s, a, "Tint");
|
||||
for params in [
|
||||
json!({"layer": a, "effect": blur}),
|
||||
json!({"layer": a, "effect": "missing"}),
|
||||
json!({"layer": a, "effect": []}),
|
||||
json!({"layer": a, "effect": 1.5}),
|
||||
json!({"layer": a, "effect": 0}),
|
||||
json!(false),
|
||||
] {
|
||||
let before = layer(&s, a);
|
||||
let n = s.history.undo.len();
|
||||
assert!(s.execute("effect.swapTintColors", params).is_err());
|
||||
assert_eq!(layer(&s, a).props, before.props);
|
||||
assert_eq!(s.history.undo.len(), n);
|
||||
}
|
||||
// A damaged project's colour parameter must also fail atomically.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.edit("Break Tint", None, |p, _| {
|
||||
p.comp_mut(cid).unwrap().layer_mut(LayerId(a)).unwrap().props.find_group_mut(tint).unwrap().prop_mut("white").unwrap().value = KV::Scalar(1.0);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = layer(&s, a);
|
||||
let n = s.history.undo.len();
|
||||
assert!(s.execute("effect.swapTintColors", json!({"layer": a, "effect": tint})).is_err());
|
||||
assert_eq!(layer(&s, a).props, before.props);
|
||||
assert_eq!(s.history.undo.len(), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_layers_clears_effect_clipboard() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
|
|
|
|||
|
|
@ -486,6 +486,7 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Stretch Factor:", "伸縮率:"),
|
||||
("Stylistic Sets", "スタイルセット"),
|
||||
("Subpixel Positioning", "サブピクセル位置決め"),
|
||||
("Swap Colors", "色を入れ替え"),
|
||||
("Swap Paint Colors (X)", "ペイントカラーを入れ替え(X)"),
|
||||
("Sync with System", "システムに合わせる"),
|
||||
("System Default", "システムの既定"),
|
||||
|
|
@ -1025,6 +1026,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
|||
("Stretch Factor:", "伸展系数:"),
|
||||
("Stylistic Sets", "样式集"),
|
||||
("Subpixel Positioning", "次像素定位"),
|
||||
("Swap Colors", "交换颜色"),
|
||||
("Swap Paint Colors (X)", "交换绘画颜色(X)"),
|
||||
("Sync with System", "与系统同步"),
|
||||
("System Default", "系统默认"),
|
||||
|
|
@ -1755,6 +1757,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
|||
("Stretch Factor:", "伸展係數:"),
|
||||
("Stylistic Sets", "樣式集"),
|
||||
("Subpixel Positioning", "次像素定位"),
|
||||
("Swap Colors", "交換顏色"),
|
||||
("Swap Paint Colors (X)", "交換繪畫顏色(X)"),
|
||||
("Sync with System", "與系統同步"),
|
||||
("System Default", "系統預設"),
|
||||
|
|
@ -2493,6 +2496,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
|||
("Stretch Factor:", "Коефіцієнт розтягнення:"),
|
||||
("Stylistic Sets", "Стилістичні набори"),
|
||||
("Subpixel Positioning", "Субпіксельне позиціювання"),
|
||||
("Swap Colors", "Поміняти кольори"),
|
||||
("Swap Paint Colors (X)", "Поміняти місцями кольори малювання (X)"),
|
||||
("Sync with System", "Синхронізувати з системою"),
|
||||
("System Default", "Стандартне системне"),
|
||||
|
|
@ -3035,6 +3039,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
|||
("Stretch Factor:", "Fator de esticamento:"),
|
||||
("Stylistic Sets", "Conjuntos estilísticos"),
|
||||
("Subpixel Positioning", "Posicionamento em subpixel"),
|
||||
("Swap Colors", "Trocar cores"),
|
||||
("Swap Paint Colors (X)", "Trocar cores de pintura (X)"),
|
||||
("System Default", "Padrão do sistema"),
|
||||
("Table", "Tabela"),
|
||||
|
|
@ -3486,6 +3491,7 @@ const CZECH: &[(&str, &str)] = &[
|
|||
("Stretch Factor:", "Koeficient roztažení:"),
|
||||
("Stylistic Sets", "Stylistické sady"),
|
||||
("Subpixel Positioning", "Subpixelové umístění"),
|
||||
("Swap Colors", "Prohodit barvy"),
|
||||
("Swap Paint Colors (X)", "Prohodit barvy malování (X)"),
|
||||
("Sync with System", "Synchronizovat se systémem"),
|
||||
("System Default", "Výchozí systémové"),
|
||||
|
|
|
|||
|
|
@ -1347,6 +1347,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() };
|
||||
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &scope, &mut actions);
|
||||
if effect == "ec.color.tint" {
|
||||
let r = Rect::from_min_size(pos2(body.min.x + PARAM_NAME_X, y + 2.0), vec2(120.0, ROW));
|
||||
let label = crate::i18n::tr("Swap Colors");
|
||||
let resp = widgets::text_button(ui, r, label, false, &t, egui::Id::new(("ec-tint-swap", g.uid)));
|
||||
app.auto.add_response(&format!("effectControls.effect.{}.swapColors", g.uid), &resp, label);
|
||||
if resp.clicked() {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": g.uid})));
|
||||
actions.push(("effect.swapTintColors".into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
}
|
||||
y += ROW + 4.0;
|
||||
}
|
||||
}
|
||||
y += 4.0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,40 @@ fn effect_controls_widgets_register_automation_ids() {
|
|||
assert!(ids(&h).contains(&pt_id), "effect point control shown for the selected effect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tint_swap_button_targets_its_instance_and_undoes_both_colors() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 640, "height": 360, "frameRate": 30, "duration": 5})).unwrap();
|
||||
let lid = s.execute("layer.newSolid", json!({"color": "#000000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let first = s.execute("effect.apply", json!({"effect": "Tint"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let target = s.execute("effect.apply", json!({"effect": "Tint"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let blur = s.execute("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
|
||||
h.run_steps(4);
|
||||
let id = format!("effectControls.effect.{target}.swapColors");
|
||||
let rect = h.state().auto.find(&id).unwrap().rect;
|
||||
assert!(ids(&h).contains(&format!("effectControls.effect.{first}.swapColors")));
|
||||
assert!(!ids(&h).contains(&format!("effectControls.effect.{blur}.swapColors")));
|
||||
let n = h.state().session.history.undo.len();
|
||||
click(&mut h, egui::pos2(rect[0] + rect[2] / 2.0, rect[1] + rect[3] / 2.0));
|
||||
let l = layer(&h.state().session, lid);
|
||||
let g = l.props.find_group(target).unwrap();
|
||||
assert_eq!(g.prop("black").unwrap().value, KV::Color([1.0; 4]));
|
||||
assert_eq!(g.prop("white").unwrap().value, KV::Color([0.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(l.props.find_group(first).unwrap().prop("black").unwrap().value, KV::Color([0.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(h.state().session.history.undo.len(), n + 1);
|
||||
assert_eq!(h.state().session.state.selected_props, vec![(LayerId(lid), target)]);
|
||||
assert!(h.state_mut().session.undo());
|
||||
let l = layer(&h.state().session, lid);
|
||||
assert_eq!(l.props.find_group(target).unwrap().prop("black").unwrap().value, KV::Color([0.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(l.props.find_group(target).unwrap().prop("white").unwrap().value, KV::Color([1.0; 4]));
|
||||
h.state_mut().ui.fx_closed.insert(target);
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().auto.elements.iter().any(|e| e.id == id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crosshair_and_eyedropper_pick_from_the_viewer() {
|
||||
let (app, x) = app();
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
|
|||
| Audio | 13 | 0 | CPU by design (applied in the mixdown) |
|
||||
| Blur & Sharpen | 16 | 15 | Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) |
|
||||
| Channel | 13 | 13 | — |
|
||||
| Color Correction | 40 | 40 | ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
|
||||
| Color Correction | 40 | 40 | Tint has Swap Colors (#963); ACES 2.0 / OCIO 2.5 (26.5); Apply Color LUT (#514) and 32-bpc blending (#494) are closed |
|
||||
| Distort | 39 | 35 | Liquify has no viewer brush; #303 (Mesh Warp drag) and #534 (CC Power Pin Unstretch) are closed |
|
||||
| Expression Controls | 9 | 9 | — |
|
||||
| Generate | 26 | 25 | Fractal on the CPU (needs f64); #526 (Reset) and #536 (Write-on marks) are closed |
|
||||
|
|
@ -66,4 +66,5 @@ the GPU is performance, not parity.
|
|||
|---|---|---|
|
||||
| 2026-10-12 | minor | Parameters 3,014 to 3,017 (effects.md); #303, #514, #526, #533–#536 marked closed instead of pending |
|
||||
| 2026-10-12 | minor | Effect colour labels (26.5) added in Effect Controls |
|
||||
| 2026-10-12 | minor | Tint's Swap Colors button and agent-drivable command (#963); undo/redo, instance targeting, animated layer-time edits and invalid-input tests |
|
||||
| 2026-10-10 | major | First effects parity checklist |
|
||||
|
|
|
|||
Loading…
Reference in New Issue