mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
#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:
7 files changed
+208
-32
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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))]);
|
||||
|
||||
Reference in new issue
Block a user