diff --git a/crates/aep/src/export/fx_defs.rs b/crates/aep/src/export/fx_defs.rs index 346f20462..92ff7c20c 100644 --- a/crates/aep/src/export/fx_defs.rs +++ b/crates/aep/src/export/fx_defs.rs @@ -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), diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 6639e649e..ad370ea1d 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -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 { 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))); diff --git a/crates/effects/src/color2/tests.rs b/crates/effects/src/color2/tests.rs index 5f57692a0..069d4c9f7 100644 --- a/crates/effects/src/color2/tests.rs +++ b/crates/effects/src/color2/tests.rs @@ -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 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ab4b5b8da..054ece456 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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; diff --git a/crates/engine/src/tests/effect_ranges.rs b/crates/engine/src/tests/effect_ranges.rs new file mode 100644 index 000000000..3581f4411 --- /dev/null +++ b/crates/engine/src/tests/effect_ranges.rs @@ -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 = 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), + } +} diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index bb206adbd..fb7dfe42b 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -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). diff --git a/crates/gpu/src/tests/fx_tone.rs b/crates/gpu/src/tests/fx_tone.rs index 40bc9483f..ca80f3836 100644 --- a/crates/gpu/src/tests/fx_tone.rs +++ b/crates/gpu/src/tests/fx_tone.rs @@ -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))]);