#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.
This commit is contained in:
arnav-hiwarkar committed 2026-11-12 15:11:21 +05:30
1 parent 216fc6052d
commit 39a3b565d8
7 files changed
+208 -32

No files matched your search

-25
View File
@@ -754,31 +754,6 @@ mod tests {
("ec.noise.noisehls", "grainSize", 3484),
("ec.noise.fractal", "subSettings/subInfluence", 3485),
("ec.noise.fractal", "subSettings/subScaling", 3486),
("ec.color.levelsic", "rgb/rgbInBlack", 3487),
("ec.color.levelsic", "red/redInBlack", 3487),
("ec.color.levelsic", "green/greenInBlack", 3487),
("ec.color.levelsic", "blue/blueInBlack", 3487),
("ec.color.levelsic", "alpha/alphaInBlack", 3487),
("ec.color.levelsic", "rgb/rgbInWhite", 3488),
("ec.color.levelsic", "red/redInWhite", 3488),
("ec.color.levelsic", "green/greenInWhite", 3488),
("ec.color.levelsic", "blue/blueInWhite", 3488),
("ec.color.levelsic", "alpha/alphaInWhite", 3488),
("ec.color.levelsic", "rgb/rgbOutBlack", 3489),
("ec.color.levelsic", "red/redOutBlack", 3489),
("ec.color.levelsic", "green/greenOutBlack", 3489),
("ec.color.levelsic", "blue/blueOutBlack", 3489),
("ec.color.levelsic", "alpha/alphaOutBlack", 3489),
("ec.color.levelsic", "rgb/rgbOutWhite", 3490),
("ec.color.levelsic", "red/redOutWhite", 3490),
("ec.color.levelsic", "green/greenOutWhite", 3490),
("ec.color.levelsic", "blue/blueOutWhite", 3490),
("ec.color.levelsic", "alpha/alphaOutWhite", 3490),
("ec.color.levelsic", "rgb/rgbGamma", 3491),
("ec.color.levelsic", "red/redGamma", 3491),
("ec.color.levelsic", "green/greenGamma", 3491),
("ec.color.levelsic", "blue/blueGamma", 3491),
("ec.color.levelsic", "alpha/alphaGamma", 3491),
("ec.distort.ccpowerpin", "expandTop", 3492),
("ec.distort.ccpowerpin", "expandLeft", 3492),
("ec.distort.ccpowerpin", "expandRight", 3492),
+16 -6
View File
@@ -252,10 +252,17 @@ impl Lv {
if clip_w {
t = t.min(1.0);
}
self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01))
let curve = crate::color_fx::levels_gamma(t, self.g.max(0.01)).clamp(-LEVELS_IC_CURVE_LIMIT, LEVELS_IC_CURVE_LIMIT);
self.ob + (self.ow - self.ob) * curve
}
}
/// How far Levels (Individual Controls)' curve may reach, far beyond any image value: with gamma
/// down to 0 (rendered as 0.01, an exponent of 100) an HDR value a few times Input White would
/// overflow `f32`, and an infinite curve times an empty output range is NaN. Bounded, the output
/// stays finite over the whole valid range (levels ±10000). The GPU (`fxt_lv`) uses the same.
const LEVELS_IC_CURVE_LIMIT: f32 = 1e6;
fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf {
let lv = LV_IDS.map(|ids| Lv {
ib: ctx.params.f(ids[0]) as f32,
@@ -978,11 +985,14 @@ fn cc_kernel(ctx: &EffectCtx, mut b: Buf) -> Buf {
pub fn specs() -> Vec<EffectSpec> {
let mut lv_params = Vec::new();
for (ids, names) in LV_IDS.iter().zip(LV_NAMES.iter()) {
lv_params.push(p(ids[0], names[0], num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)));
lv_params.push(p(ids[1], names[1], num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)));
lv_params.push(p(ids[2], names[2], num(1.0), slider(0.1, 10.0, 0.1, 3.0, 2)));
lv_params.push(p(ids[3], names[3], num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)));
lv_params.push(p(ids[4], names[4], num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)));
// After Effects' valid ranges (#3487-#3491): levels -10000..10000 and gamma from 0 (the
// renderer floors it at 0.01); gamma keeps its wider maximum. The sliders stay 0..1.
let level = || slider(-10000.0, 10000.0, 0.0, 1.0, 3);
lv_params.push(p(ids[0], names[0], num(0.0), level()));
lv_params.push(p(ids[1], names[1], num(1.0), level()));
lv_params.push(p(ids[2], names[2], num(1.0), slider(0.0, 10.0, 0.1, 3.0, 2)));
lv_params.push(p(ids[3], names[3], num(0.0), level()));
lv_params.push(p(ids[4], names[4], num(1.0), level()));
}
lv_params.push(p("clipToOutputBlack", "Clip To Output Black", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP)));
lv_params.push(p("clipToOutputWhite", "Clip To Output White", Value::Enum(2), popup(&crate::color_fx::LEVELS_CLIP)));
+29
View File
@@ -270,6 +270,35 @@ fn levels_ic_clip_popups() {
assert!(run("ec.color.levelsic", &img, &off).data[0][0] > 1.5);
}
/// #3487-#3491: over After Effects' valid ranges (levels ±10000, gamma from 0) the output stays
/// finite: gamma 0 renders as 0.01, whose curve on an HDR value overflows `f32` unbounded.
#[test]
fn levels_ic_stays_finite_over_the_whole_range() {
let img = Image::filled(2, 2, [3.0, 0.5, -2.0, 1.0]);
for (ib, iw, ob, ow) in [
(0.0, 1.0, 0.0, 1.0),
(0.0, 1.0, 1.0, 1.0),
(-10000.0, 10000.0, -10000.0, 10000.0),
(10000.0, -10000.0, 10000.0, -10000.0),
(0.0, 1e-3, -10000.0, 10000.0),
] {
let lv = [
("rgb/rgbInBlack", num(ib)),
("rgb/rgbInWhite", num(iw)),
("rgb/rgbGamma", num(0.0)),
("rgb/rgbOutBlack", num(ob)),
("rgb/rgbOutWhite", num(ow)),
("clipToOutputBlack", Value::Enum(0)),
("clipToOutputWhite", Value::Enum(0)),
];
let out = run("ec.color.levelsic", &img, &lv);
assert!(out.data.iter().flatten().all(|v| v.is_finite()), "{ib} {iw} {ob} {ow}: {:?}", out.data[0]);
}
// Gamma 0 is the limit of a small gamma: below Input White the curve goes to 0.
let dark = run("ec.color.levelsic", &Image::filled(1, 1, [0.5, 0.5, 0.5, 1.0]), &[("rgb/rgbGamma", num(0.0))]);
assert!(dark.data[0][0] < 1e-6, "{:?}", dark.data[0]);
}
/// A host whose other layers and other frames are a fixed image.
struct ImgHost(Image);
impl crate::EffectHost for ImgHost {
+1
View File
@@ -12,6 +12,7 @@ mod crypto_pick;
mod delete_keys;
mod disk_cache;
mod display_pass;
mod effect_ranges;
mod effects;
mod essential;
mod essential_more;
+148
View File
@@ -0,0 +1,148 @@
//! 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),
}
}
+3 -1
View File
@@ -40,7 +40,9 @@ fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 {
if (clip_w) {
t = min(t, 1.0);
}
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, max(data[o + 2u], 0.01));
// color2::LEVELS_IC_CURVE_LIMIT keeps the curve finite.
let curve = clamp(fxt_gamma(t, max(data[o + 2u], 0.01)), -1e6, 1e6);
return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * curve;
}
// color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white).
+11
View File
@@ -29,6 +29,17 @@ fn levels_and_gamma() {
);
effect_case("ec.color.levelsic", &[("green/greenInBlack", n(0.2)), ("green/greenInWhite", n(0.6)), ("clipToOutputWhite", e(0))]);
effect_case("ec.color.levelsic", &[("alpha/alphaOutBlack", n(0.3))]);
// #3487-#3491: the ends of the valid ranges, the curve bounded where gamma 0 would overflow.
effect_case(
"ec.color.levelsic",
&[
("rgb/rgbInWhite", n(0.2)),
("rgb/rgbGamma", n(0.0)),
("red/redOutBlack", n(-10000.0)),
("blue/blueOutWhite", n(10000.0)),
("clipToOutputWhite", e(0)),
],
);
effect_case("ec.color.levels", &[("redInBlack", n(0.1)), ("greenGamma", n(1.5)), ("blueOutWhite", n(0.7)), ("inWhite", n(0.9))]);
effect_case("ec.color.levels", &[("alphaInWhite", n(0.6)), ("redInWhite", n(0.5)), ("redClipToOutputWhite", e(0))]);
effect_case("ec.color.gammapedestalgain", &[("redGamma", n(1.4)), ("greenPedestal", n(0.1)), ("blueGain", n(0.8))]);