Files
effectcraft/crates/engine/src/tests/effect_ranges.rs
T
arnav-hiwarkar 39a3b565d8 #3487, #3488, #3489, #3490, #3491: Levels (Individual Controls): levels and gamma take After Effects' ranges
Input/Output Black and White of every channel take -10000..10000 and Gamma
goes down to 0 (it keeps its wider maximum, 10), After Effects' recorded
valid ranges in the same units (the .aep mapping is Conv::Plain). Defaults
and the 0..1 / 0.1..3 sliders are unchanged.

Gamma 0 renders as the existing 0.01 floor, an exponent of 100, so an HDR
value a few times Input White would overflow f32 and an infinite curve
times an empty output range is NaN. The curve is now bounded at +-1e6 on
the CPU (Lv::apply) and the GPU (fxt_lv), so the output stays finite over
the whole range; values below the bound are unchanged.

The engine regression (tests/effect_ranges.rs) sets both ends of each row
through prop.set, checks undo/redo, save and reopen, and that a project
saved with the narrower ranges opens with the new ones and its values.
2026-11-12 15:11:21 +05:30

149 lines
7.8 KiB
Rust

//! Effect parameters take the whole valid range After Effects records for them (fx_defs in the
//! aep crate), end to end: `prop.set` keeps both ends, undo and redo restore them, and a saved
//! project reopens with them. The aep crate's
//! `effect_hard_ranges_cover_recorded_after_effects_ranges` checks the ranges themselves
//! against that table.
use effectcraft_project::{LayerId, Node, ParamUi, PropGroup};
use serde_json::{Value, json};
use crate::Session;
/// (issue, effect, parameter path, lowest, highest): the ends of After Effects' valid range,
/// in EffectCraft's units, each outside the range the effect accepted before the issue.
#[rustfmt::skip]
const ROWS: &[(u32, &str, &str, f64, f64)] = &[
(3487, "ec.color.levelsic", "rgb/rgbInBlack", -10000.0, 10000.0),
(3488, "ec.color.levelsic", "rgb/rgbInWhite", -10000.0, 10000.0),
(3489, "ec.color.levelsic", "rgb/rgbOutBlack", -10000.0, 10000.0),
(3490, "ec.color.levelsic", "rgb/rgbOutWhite", -10000.0, 10000.0),
(3491, "ec.color.levelsic", "rgb/rgbGamma", 0.0, 10.0),
(3487, "ec.color.levelsic", "red/redInBlack", -10000.0, 10000.0),
(3488, "ec.color.levelsic", "red/redInWhite", -10000.0, 10000.0),
(3489, "ec.color.levelsic", "red/redOutBlack", -10000.0, 10000.0),
(3490, "ec.color.levelsic", "red/redOutWhite", -10000.0, 10000.0),
(3491, "ec.color.levelsic", "red/redGamma", 0.0, 10.0),
(3487, "ec.color.levelsic", "green/greenInBlack", -10000.0, 10000.0),
(3488, "ec.color.levelsic", "green/greenInWhite", -10000.0, 10000.0),
(3489, "ec.color.levelsic", "green/greenOutBlack", -10000.0, 10000.0),
(3490, "ec.color.levelsic", "green/greenOutWhite", -10000.0, 10000.0),
(3491, "ec.color.levelsic", "green/greenGamma", 0.0, 10.0),
(3487, "ec.color.levelsic", "blue/blueInBlack", -10000.0, 10000.0),
(3488, "ec.color.levelsic", "blue/blueInWhite", -10000.0, 10000.0),
(3489, "ec.color.levelsic", "blue/blueOutBlack", -10000.0, 10000.0),
(3490, "ec.color.levelsic", "blue/blueOutWhite", -10000.0, 10000.0),
(3491, "ec.color.levelsic", "blue/blueGamma", 0.0, 10.0),
(3487, "ec.color.levelsic", "alpha/alphaInBlack", -10000.0, 10000.0),
(3488, "ec.color.levelsic", "alpha/alphaInWhite", -10000.0, 10000.0),
(3489, "ec.color.levelsic", "alpha/alphaOutBlack", -10000.0, 10000.0),
(3490, "ec.color.levelsic", "alpha/alphaOutWhite", -10000.0, 10000.0),
(3491, "ec.color.levelsic", "alpha/alphaGamma", 0.0, 10.0),
];
fn path(effect: &str, param: &str) -> String {
format!("effects/{effect}/{param}")
}
fn read(s: &mut Session, layer: u64, effect: &str, param: &str) -> f64 {
let got = s.execute("prop.get", json!({"layer": layer, "path": path(effect, param)})).unwrap();
got["value"].as_f64().unwrap_or_else(|| panic!("{effect} {param}: {got}"))
}
fn set(s: &mut Session, layer: u64, effect: &str, param: &str, v: f64) -> Value {
s.execute("prop.set", json!({"layer": layer, "path": path(effect, param), "value": v})).unwrap()
}
/// A composition with two solids, each with one instance of every effect in [`ROWS`].
fn session() -> (Session, u64, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 2})).unwrap();
let mut layers = [0; 2];
for l in &mut layers {
*l = s.execute("layer.newSolid", json!({"color": "#808080", "width": 64, "height": 36})).unwrap()["layer"].as_u64().unwrap();
let mut effects: Vec<&str> = ROWS.iter().map(|r| r.1).collect();
effects.dedup();
for e in effects {
s.execute("effect.apply", json!({"layers": [*l], "effect": e})).unwrap();
}
}
(s, layers[0], layers[1])
}
/// Both ends of every row survive `prop.set`, undo / redo, and a save and reopen.
#[test]
fn effect_parameters_keep_both_ends_of_the_after_effects_range() {
let (mut s, low, high) = session();
for &(issue, e, p, lo, hi) in ROWS {
assert_eq!(set(&mut s, high, e, p, lo), json!(lo), "#{issue} {e} {p}: prop.set reply");
assert_eq!(read(&mut s, high, e, p), lo, "#{issue} {e} {p}");
set(&mut s, high, e, p, hi);
assert_eq!(read(&mut s, high, e, p), hi, "#{issue} {e} {p}");
assert!(s.undo());
assert_eq!(read(&mut s, high, e, p), lo, "#{issue} {e} {p}: undo");
assert!(s.redo());
assert_eq!(read(&mut s, high, e, p), hi, "#{issue} {e} {p}: redo");
set(&mut s, low, e, p, lo);
}
let dir = std::env::temp_dir().join(format!("ec-effect-ranges-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("ranges.ecproj").to_string_lossy().to_string();
s.execute("file.save", json!({"path": file})).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": file})).unwrap();
let _ = std::fs::remove_dir_all(dir);
for &(issue, e, p, lo, hi) in ROWS {
assert_eq!(read(&mut s2, low, e, p), lo, "#{issue} {e} {p}: reopened");
assert_eq!(read(&mut s2, high, e, p), hi, "#{issue} {e} {p}: reopened");
}
}
/// A project saved while these ranges were narrower stored that range with each instance
/// (`ParamUi`) and values clamped to it: opening it takes the effect's current range and keeps
/// the stored values, so the new ends can be set.
#[test]
fn projects_saved_with_the_narrower_ranges_open_with_the_new_ones() {
let (mut s, low, _) = session();
let cid = s.active_comp_id().unwrap();
let mut old = (*s.project).clone();
let stored: Vec<f64> = ROWS.iter().map(|&(_, e, p, ..)| read(&mut s, low, e, p)).collect();
{
let effectcraft_project::ItemKind::Comp(comp) = &mut old.items.get_mut(&cid).unwrap().kind else { panic!("not a composition") };
let l = std::sync::Arc::make_mut(comp).layers.find_mut(|l| l.id == LayerId(low)).unwrap();
for (&(_, e, p, ..), v) in ROWS.iter().zip(&stored) {
let g = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|n| match n {
Node::Group(g) if g.match_id == e => Some(g),
_ => None,
});
let pr = prop_mut(g.unwrap(), p).unwrap_or_else(|| panic!("{e} {p}"));
// The narrowest stored range that still holds the stored value.
pr.ui = ParamUi::Slider { min: *v, max: *v, slider_min: *v, slider_max: *v, decimals: 1 };
}
}
let dir = std::env::temp_dir().join(format!("ec-effect-ranges-old-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("old.ecproj").to_string_lossy().to_string();
std::fs::write(&file, old.to_json()).unwrap();
let mut s2 = Session::default();
s2.execute("file.open", json!({"path": file})).unwrap();
let _ = std::fs::remove_dir_all(dir);
for (&(issue, e, p, lo, hi), v) in ROWS.iter().zip(&stored) {
assert_eq!(read(&mut s2, low, e, p), *v, "#{issue} {e} {p}: the stored value is kept");
let spec = effectcraft_effects::find(e).unwrap().params.iter().find(|q| q.id == p).unwrap();
let uid = s2.execute("prop.get", json!({"layer": low, "path": path(e, p)})).unwrap()["uid"].as_u64().unwrap();
let layer = s2.project.comp(cid).unwrap().layer(LayerId(low)).unwrap();
assert_eq!(layer.props.find(uid).unwrap().ui, spec.ui, "#{issue} {e} {p}: the range is refreshed");
set(&mut s2, low, e, p, lo);
assert_eq!(read(&mut s2, low, e, p), lo, "#{issue} {e} {p}");
set(&mut s2, low, e, p, hi);
assert_eq!(read(&mut s2, low, e, p), hi, "#{issue} {e} {p}");
}
}
/// The property at `path` (`group/param`) in an effect instance.
fn prop_mut<'a>(g: &'a mut PropGroup, path: &str) -> Option<&'a mut effectcraft_project::Property> {
match path.split_once('/') {
None => g.get_mut(path),
Some((first, rest)) => prop_mut(g.sub_mut(first)?, rest),
}
}