diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index c7a012c76..6e5b8d6cc 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -6,6 +6,7 @@ //! effectcraft-cli exec [--params JSON] run one command //! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`) //! effectcraft-cli run [ …] run several commands in order +//! effectcraft-cli run --args-file F the same, one argument per line of F //! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) //! effectcraft-cli get [--time S] read a property //! effectcraft-cli set [--time S] [--expression E] @@ -96,6 +97,7 @@ const USAGE: &str = "usage: effectcraft-cli [--params JSON] run one engine command (exec --list: list them, as `commands`) run [ ...] run several commands in order + run --args-file F the same, one argument per line of F (no command-line length limit) props [--flat] [--time S] a layer's property tree with paths get [--time S] read a property set [--time S] [--expression E] @@ -148,6 +150,7 @@ const VALUED: &[&str] = &[ "--expression", "--depth", "--eval", + "--args-file", // render "--template", "--values", @@ -515,10 +518,15 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { emit(&with_saved(r, saved), json_out); } "run" => { - if args.pos.is_empty() { + let mut pairs = args.pos.clone(); + // Windows caps a command line at 32 767 characters: long batches come from a file. + if let Some(f) = args.opt("--args-file") { + let text = std::fs::read_to_string(f).map_err(|e| Failure::Usage(format!("--args-file {f}: {e}")))?; + pairs.extend(text.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from)); + } + if pairs.is_empty() { return usage_err("run needs [] pairs"); } - let pairs = args.pos.clone(); let mut b = backend(args, true)?; let mut results = vec![]; let mut i = 0; diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index 1111db403..538887d9b 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -110,6 +110,29 @@ fn exec_set_get_with_saved_project() { assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}"); } +/// `run --args-file` reads its command / params pairs from a file, one per line: a batch longer +/// than a Windows command line (32 767 characters, os error 206) still runs. +#[test] +fn run_reads_arguments_from_a_file() { + let proj = tmp("args-file.ecproj"); + let p = proj.to_str().unwrap(); + let mut lines = vec!["comp.new".to_string(), json!({"name": "A", "width": 64, "height": 64}).to_string()]; + for i in 0..400 { + lines.push("layer.newSolid".into()); + lines.push(json!({"width": 8, "height": 8, "name": format!("Solid with a long name to pass the limit {i:04}")}).to_string()); + lines.push(String::new()); + } + let text = lines.join("\n"); + assert!(text.len() > 32_767); + let file = tmp("args-file.txt"); + std::fs::write(&file, text).unwrap(); + let v = ok_json(&["run", "--args-file", file.to_str().unwrap(), "--empty", "--save-as", p]); + assert_eq!(v["result"].as_array().map(Vec::len), Some(401), "{v}"); + let out = bin().args(["run", "--args-file", "no/such/file.txt", "--empty"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&out.stderr).contains("--args-file no/such/file.txt")); +} + #[test] fn render_frame_writes_png() { let out = tmp("f.png"); diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 7884e8084..9eb8f4dd9 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -37,16 +37,24 @@ fn index_map(b: &Buf, a: &AuxChannels) -> Vec> { /// [`index_map`] sampling each pixel at sub-pixel position `at` (0..1). fn index_map_at(b: &Buf, a: &AuxChannels, at: [f64; 2]) -> Vec> { let (w, h) = (b.img.width as usize, b.img.height as usize); - let inv = 1.0 / b.scale.max(1e-9); (0..w * h) .into_par_iter() .map(|i| { - let (x, y) = ((i % w) as f64 + at[0], (i / w) as f64 + at[1]); - a.index_at((x - b.offset[0]) * inv, (y - b.offset[1]) * inv) + let [x, y] = aux_point(b.scale, b.offset, [(i % w) as f64 + at[0], (i / w) as f64 + at[1]]); + a.index_at(x, y) }) .collect() } +/// The layer point where 3D Channel effects look up the aux for buffer position `p` (pixel +/// `i` + sub-pixel offset) of a buffer with `scale` and `offset`. The Cryptomatte pick calls +/// it too, so the two can't pick different aux pixels. +#[inline] +pub fn aux_point(scale: f64, offset: [f64; 2], p: [f64; 2]) -> [f64; 2] { + let inv = 1.0 / scale.max(1e-9); + [(p[0] - offset[0]) * inv, (p[1] - offset[1]) * inv] +} + /// A channel sampled into the buffer grid (`fill` outside). fn plane_of(b: &Buf, idx: &[Option], ch: &[f32], fill: f32) -> Plane { Plane { w: b.img.width as usize, h: b.img.height as usize, data: idx.par_iter().map(|i| i.map_or(fill, |i| ch[i])).collect() } @@ -326,7 +334,7 @@ fn wild(pat: &str, name: &str) -> bool { /// Split a selection at its commas. A name in double or single quotes stays whole, commas /// included (Blender allows `Green, Glossy`), and a backslash takes the next character /// literally (`Green\, Glossy`, `\"`). Unquoted text is trimmed; the quotes are dropped. -fn selection_names(sel: &str) -> Vec { +pub(crate) fn selection_names(sel: &str) -> Vec { let mut out = vec![]; let mut cur = String::new(); let mut quote: Option = None; diff --git a/crates/effects/src/color2.rs b/crates/effects/src/color2.rs index 71dee87b4..9547fd775 100644 --- a/crates/effects/src/color2.rs +++ b/crates/effects/src/color2.rs @@ -55,6 +55,17 @@ impl Curve { /// Parse "x,y x,y …" (separators: whitespace or ';'). Returns None for an identity curve or /// fewer than two valid points. pub fn parse(s: &str) -> Option { + Self::parse_with(s, false) + } + + /// [`Curve::parse`] interpolated as After Effects' Curves effect does: a natural cubic spline + /// through the points, clamped to 0..1 (AE 2026: RGB points (0,0) (125,199) (255,255) and + /// Red (0,0) (128,44) (255,255) map every grey step exactly as the natural spline does). + pub fn parse_natural(s: &str) -> Option { + Self::parse_with(s, true) + } + + fn parse_with(s: &str, natural: bool) -> Option { let mut pts: Vec<(f32, f32)> = s .split(|c: char| c.is_whitespace() || c == ';') .filter(|t| !t.is_empty()) @@ -74,6 +85,12 @@ impl Curve { let ys: Vec = pts.iter().map(|p| p.1).collect(); let n = xs.len(); let d: Vec = (0..n - 1).map(|k| (ys[k + 1] - ys[k]) / (xs[k + 1] - xs[k])).collect(); + if natural { + let ms = natural_slopes(&xs, &d); + let mut c = Curve { xs, ys, ms, lut: Vec::new() }; + c.lut = (0..=LUT_N).map(|i| c.eval_exact(i as f32 / LUT_N as f32).clamp(0.0, 1.0)).collect(); + return Some(c); + } let mut ms = vec![0.0f32; n]; ms[0] = d[0]; ms[n - 1] = d[n - 2]; @@ -136,6 +153,40 @@ impl Curve { } } +/// Knot slopes of the natural cubic spline (zero curvature at both ends) through points with +/// abscissae `xs` and segment slopes `d`; as Hermite tangents they reproduce the spline exactly. +fn natural_slopes(xs: &[f32], d: &[f32]) -> Vec { + let n = xs.len(); + if n < 3 { + return vec![d.first().copied().unwrap_or(0.0); n]; + } + let h: Vec = (0..n - 1).map(|k| f64::from(xs[k + 1] - xs[k])).collect(); + let dd: Vec = d.iter().map(|&v| f64::from(v)).collect(); + // Second derivatives M[1..n-1] (M[0] = M[n-1] = 0): tridiagonal system, Thomas algorithm. + let m = n - 2; + let (mut a, mut b, mut c, mut r) = (vec![0.0; m], vec![0.0; m], vec![0.0; m], vec![0.0; m]); + for i in 0..m { + let k = i + 1; + a[i] = h[k - 1]; + b[i] = 2.0 * (h[k - 1] + h[k]); + c[i] = h[k]; + r[i] = 6.0 * (dd[k] - dd[k - 1]); + } + for i in 1..m { + let w = a[i] / b[i - 1]; + b[i] -= w * c[i - 1]; + r[i] -= w * r[i - 1]; + } + let mut sec = vec![0.0f64; n]; + for i in (0..m).rev() { + let next = if i + 1 < m { sec[i + 2] } else { 0.0 }; + sec[i + 1] = (r[i] - c[i] * next) / b[i]; + } + let mut ms: Vec = (0..n - 1).map(|k| (dd[k] - h[k] * (2.0 * sec[k] + sec[k + 1]) / 6.0) as f32).collect(); + ms.push((dd[n - 2] + h[n - 2] * (sec[n - 2] + 2.0 * sec[n - 1]) / 6.0) as f32); + ms +} + fn ev(c: &Option, v: f32) -> f32 { c.as_ref().map(|c| c.eval(v)).unwrap_or(v) } @@ -145,7 +196,7 @@ fn ev(c: &Option, v: f32) -> f32 { /// sorted on load); the default `"0,0 1,1"` is the identity. Colour channels go through the /// master RGB curve first, then their own curve. fn curves(ctx: &EffectCtx, mut b: Buf) -> Buf { - let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse(ctx.params.s(id))); + let [m, r, g, bl, a] = ["rgb", "red", "green", "blue", "alpha"].map(|id| Curve::parse_natural(ctx.params.s(id))); if m.is_none() && r.is_none() && g.is_none() && bl.is_none() && a.is_none() { return b; } @@ -201,7 +252,7 @@ impl Lv { if clip_w { t = t.min(1.0); } - self.ob + (self.ow - self.ob) * t.max(0.0).powf(1.0 / self.g.max(0.01)) + self.ob + (self.ow - self.ob) * crate::color_fx::levels_gamma(t, self.g.max(0.01)) } } @@ -217,7 +268,10 @@ fn levels_ic(ctx: &EffectCtx, mut b: Buf) -> Buf { return b; } let clip = crate::color_fx::levels_clip(ctx); - map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[i + 1].apply(lv[0].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); + // Each channel's own controls first, then the RGB controls: After Effects 2026 with RGB + // (0.1, 0.9, gamma 1.5, 0.05, 0.95) and Green Input White 0.8 maps grey 32/255 to green + // 52/255 (RGB first would give 45). + map_ca(&mut b.img, |c, a| ([0, 1, 2].map(|i| lv[0].apply(lv[i + 1].apply(c[i], clip), clip)), lv[4].apply(a, clip).clamp(0.0, 1.0))); b } @@ -1139,6 +1193,86 @@ mod tests { assert!((red.data[0][0] - 1.0).abs() < 1e-4 && (red.data[0][1] - 0.5).abs() < 1e-6); } + /// After Effects 2026, 32 bpc project, Levels (Individual Controls) with RGB Input Black 0.1, + /// Input White 0.9, Gamma 1.5, Output Black 0.05, Output White 0.95, Red Gamma 0.8, Green + /// Input White 0.8, Blue Output Black 0.2, both clips "Off for 32 bpc Color", on grey steps: + /// the channel controls run first, nothing clips (8-bit captures clamp the results). + #[test] + fn levels_ic_matches_after_effects_in_32_bpc() { + let steps = [0, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let ae = [ + [0, 0, 70], + [0, 0, 88], + [0, 52, 104], + [61, 108, 132], + [102, 150, 158], + [138, 187, 181], + [171, 220, 203], + [203, 250, 223], + [233, 255, 243], + [255, 255, 255], + ]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let vals = [ + ("rgb/rgbInBlack", Value::Scalar(0.1)), + ("rgb/rgbInWhite", Value::Scalar(0.9)), + ("rgb/rgbGamma", Value::Scalar(1.5)), + ("rgb/rgbOutBlack", Value::Scalar(0.05)), + ("rgb/rgbOutWhite", Value::Scalar(0.95)), + ("red/redGamma", Value::Scalar(0.8)), + ("green/greenInWhite", Value::Scalar(0.8)), + ("blue/blueOutBlack", Value::Scalar(0.2)), + ]; + let s = find("ec.color.levelsic").unwrap(); + let mut params = Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }; + for (k, v) in &vals { + params.values.insert(k.to_string(), v.clone()); + } + let render = |float_depth: bool| { + let env = crate::EffectEnv { float_depth, ..Default::default() }; + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [10.0, 1.0], seed: 1, adjustment: false, env }; + (s.render)(&ctx, Buf { img: img.clone(), offset: [0.0, 0.0], scale: 1.0 }).img + }; + let out = render(true); + for (x, want) in ae.iter().enumerate() { + let p = out.get(x as i64, 0); + for c in 0..3 { + let got = p[c].clamp(0.0, 1.0) * 255.0; + assert!((got - want[c] as f32).abs() <= 2.0, "step {} channel {c}: {got} vs AE {}", steps[x], want[c]); + } + } + // In 8 / 16 bpc "Off for 32 bpc Color" clips: black stops at Output Black. + let clipped = render(false); + assert!((clipped.get(0, 0)[0] - 0.05).abs() < 1e-5, "{:?}", clipped.get(0, 0)); + } + + /// After Effects 2026 (cases 962 / 963): Curves through RGB (0,0) (125,199) + /// (255,255) and Red (0,0) (128,44) (255,255) on grey steps 0..255, 8-bit results. + #[test] + fn curves_follow_a_natural_spline_as_in_after_effects() { + let steps = [0u8, 16, 32, 64, 96, 128, 160, 192, 224, 255]; + let mut img = Image::new(10, 1); + for (x, v) in steps.iter().enumerate() { + let v = *v as f32 / 255.0; + img.set(x as u32, 0, [v, v, v, 1.0]); + } + let p = |pts: &[(f32, f32)]| Value::Str(pts.iter().map(|(x, y)| format!("{},{}", x / 255.0, y / 255.0)).collect::>().join(" ")); + let rgb = run("ec.color.curves", &img, &[("rgb", p(&[(0.0, 0.0), (125.0, 199.0), (255.0, 255.0)]))]); + let red = run("ec.color.curves", &img, &[("red", p(&[(0.0, 0.0), (128.0, 44.0), (255.0, 255.0)]))]); + let ae_rgb = [0, 30, 59, 115, 164, 202, 227, 242, 250, 255]; + let ae_red = [0, 0, 1, 6, 19, 44, 83, 135, 194, 255]; + for x in 0..10 { + let g = (rgb.get(x as i64, 0)[1] * 255.0).round() as i32; + let r = (red.get(x as i64, 0)[0] * 255.0).round() as i32; + assert!((g - ae_rgb[x]).abs() <= 1, "RGB step {}: {g} vs AE {}", steps[x], ae_rgb[x]); + assert!((r - ae_red[x]).abs() <= 1, "Red step {}: {r} vs AE {}", steps[x], ae_red[x]); + } + } + #[test] fn levels_ic_identity_and_red_only() { let img = ramp(); diff --git a/crates/effects/src/color_fx.rs b/crates/effects/src/color_fx.rs index cf6698fee..402d0ebe2 100644 --- a/crates/effects/src/color_fx.rs +++ b/crates/effects/src/color_fx.rs @@ -14,12 +14,27 @@ fn mix(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t] } +/// Rec. 601 luminance on the working-space values (sRGB working space, not linearised: red +/// 76/255, green 150/255, blue 29/255), as After Effects 2026 renders Tint, Tritone, Luma Key and +/// Extract. Tritone, case 718: a red solid with Blend With Original 25 gives (132, 23, 46), from +/// luminance 0.299; Rec. 709's 0.213 gave (113, 16, 49); on a grey ramp both weightings agree +/// (case 815). Luma Key and Extract: Key Out Darker / Extract Black Point at 65, 166 and 24 on +/// pure red, green and blue keep red (601: 76, 709: 54) and blue (29 / 18) and key out green +/// (150 / 182). The GPU kernels (`pointwise` op 1, `fx_color` op 4, `fxk_key_luma`) mirror it. +pub(crate) fn luma_601(c: [f32; 3]) -> f32 { + LUMA_601[0] * c[0] + LUMA_601[1] * c[1] + LUMA_601[2] * c[2] +} + +/// The Rec. 601 luma weights of [`luma_601`]. The WGSL kernels repeat them as literals; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const LUMA_601: [f32; 3] = [0.299, 0.587, 0.114]; + fn tint(ctx: &EffectCtx, mut b: Buf) -> Buf { let black = ctx.params.color("black"); let white = ctx.params.color("white"); let amt = ctx.params.f("amount") as f32 / 100.0; b.img.map_straight(|c| { - let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let l = luma_601(c).clamp(0.0, 1.0); let t = [black[0] + (white[0] - black[0]) * l, black[1] + (white[1] - black[1]) * l, black[2] + (white[2] - black[2]) * l]; mix(c, t, amt) }); @@ -32,7 +47,7 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { let sh = ctx.params.color("shadows"); let blend = 1.0 - ctx.params.f("blend") as f32 / 100.0; b.img.map_straight(|c| { - let l = luminance(c[0], c[1], c[2]).clamp(0.0, 1.0); + let l = luma_601(c).clamp(0.0, 1.0); let t = if l < 0.5 { mix([sh[0], sh[1], sh[2]], [mid[0], mid[1], mid[2]], l * 2.0) } else { @@ -49,18 +64,22 @@ fn tritone(ctx: &EffectCtx, mut b: Buf) -> Buf { /// power toward white (`1-(1-v)^e`) or black (`v^e`); contrast uses `g = 1 + 0.53 c` instead of /// the old `2^(2c)` (that crushed 0.25 to ~8/255 at Contrast 100; AE is ~40/255). Values above 1 /// pass through. The GPU kernel mirrors it. +/// +/// Negative Brightness and Contrast are the inverse curves of the positive ones (After Effects +/// 2026 ramps: Brightness −40 is Brightness +40 inverted within 2.6/255, Contrast −50 likewise), +/// so they use the reciprocal exponents: Brightness −40 was 20/255 off AE, now 6.2. pub fn bc_modern(v: f32, b: f32, c: f32) -> f32 { if v >= 1.0 { return v; } let mut v = v.max(0.0); - if b > 0.0 { - v = 1.0 - (1.0 - v).powf(2f32.powf(1.24 * b)); - } else if b < 0.0 { - v = v.powf(2f32.powf(-1.32 * b)); + if b != 0.0 { + let e = 2f32.powf(1.24 * b.abs()); + v = 1.0 - (1.0 - v).powf(if b > 0.0 { e } else { 1.0 / e }); } if c != 0.0 { - let g = (1.0 + 0.53 * c).max(0.05); + let g = (1.0 + 0.53 * c.abs()).max(0.05); + let g = if c > 0.0 { g } else { 1.0 / g }; v = if v < 0.5 { 0.5 * (2.0 * v).powf(g) } else { 1.0 - 0.5 * (2.0 - 2.0 * v).max(0.0).powf(g) }; } v @@ -197,16 +216,32 @@ fn hue_saturation(ctx: &EffectCtx, mut b: Buf) -> Buf { pub const LEVELS_CLIP: [&str; 3] = ["Off", "On", "Off for 32 bpc Color"]; /// Whether Levels clips below Input Black / above Input White. "Off for 32 bpc Color" clips -/// (effects can't see the project's bit depth; 8/16 bpc behaviour). The hidden `noClip` switch -/// of projects saved before the two popups existed turns both off. +/// in 8 and 16 bpc projects and not in 32 bpc ones (After Effects 2026, Levels (Individual +/// Controls) in a 32 bpc project: values below Input Black end below Output Black). The hidden +/// `noClip` switch of projects saved before the two popups existed turns both off. pub fn levels_clip(ctx: &EffectCtx) -> (bool, bool) { if ctx.params.b("noClip") { return (false, false); } - let on = |id: &str| ctx.params.get(id).is_none_or(|v| v.as_enum() != 0); + let on = |id: &str| levels_clip_on(ctx, id); (on("clipToOutputBlack"), on("clipToOutputWhite")) } +/// One Clip To Output popup: 0 Off, 1 On, 2 Off for 32 bpc Color (missing: clip). +pub fn levels_clip_on(ctx: &EffectCtx, id: &str) -> bool { + match ctx.params.get(id).map(|v| v.as_enum()) { + Some(0) => false, + Some(2) => !ctx.env.float_depth, + _ => true, + } +} + +/// Levels' gamma on the normalised input `t`: unclipped values below Input Black extrapolate +/// (the curve mirrored through 0) instead of stopping at Output Black. +pub fn levels_gamma(t: f32, g: f32) -> f32 { + if t >= 0.0 { t.powf(1.0 / g) } else { -(-t).powf(1.0 / g) } +} + /// Levels' Channel popup. pub const LEVELS_CHANNELS: [&str; 5] = ["RGB", "Red", "Green", "Blue", "Alpha"]; @@ -232,8 +267,7 @@ fn level(v: f32, [ib, iw, g, ob, ow]: [f32; 5], (clip_b, clip_w): (bool, bool)) if clip_w { t = t.min(1.0); } - let t = t.max(0.0).powf(1.0 / g); - ob + (ow - ob) * t + ob + (ow - ob) * levels_gamma(t, g) } const LEVELS_IDENTITY: [f32; 5] = [0.0, 1.0, 1.0, 0.0, 1.0]; @@ -276,7 +310,7 @@ pub fn levels_channel_settings(ctx: &EffectCtx) -> [([f32; 5], (bool, bool)); 4] let ids = levels_channel_ids(i + 1); let v: [f32; 5] = std::array::from_fn(|k| ctx.params.get(&ids[k]).map(|v| v.as_f64() as f32).unwrap_or(LEVELS_IDENTITY[k])); let pre = LEVELS_CHANNEL_PREFIX[i]; - let on = |id: String| ctx.params.get(&id).is_none_or(|v| v.as_enum() != 0); + let on = |id: String| levels_clip_on(ctx, &id); let clip = if ctx.params.b("noClip") { (false, false) } else { (on(format!("{pre}ClipToOutputBlack")), on(format!("{pre}ClipToOutputWhite"))) }; ([v[0], v[1], v[2].max(0.01), v[3], v[4]], clip) }) @@ -304,6 +338,18 @@ pub fn exposure_settings(ctx: &EffectCtx) -> [(f32, f32, f32); 3] { } } +/// The power curve Exposure's own linear-light conversion uses (Bypass Linear Light Conversion +/// off). After Effects decodes with a pure 2.2 power, not the piecewise sRGB curve: Offset 0.0005 +/// on black measures 8/255 there, which 2.2 predicts (8.06) and sRGB does not (1.65). +/// `kernels.wgsl` repeats it as a literal; the GPU test `shader_constants_match_the_cpu` checks it. +pub const EXPOSURE_LINEAR_GAMMA: f32 = 2.2; + +/// Exposure: per channel, `(value · 2^exposure + offset)^(1 / gamma)`, in linear light unless +/// Bypass Linear Light Conversion is on. The conversion is the effect's own pure 2.2 power curve +/// (see [`EXPOSURE_LINEAR_GAMMA`]), applied whatever the project's working space, also when it +/// is already linear (Linearize Working Space), as before. Negative values (32 bpc, or a +/// negative Offset) go through the curves mirrored, so a negative Offset darkens smoothly past +/// black and the default settings leave every value unchanged. fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { let s = exposure_settings(ctx); let bypass = ctx.params.b("bypassLinearLight"); @@ -313,9 +359,9 @@ fn exposure(ctx: &EffectCtx, mut b: Buf) -> Buf { if bypass { return mirror_pow(c[i] * e + off, 1.0 / g); } - let lin = effectcraft_color::srgb_to_linear(c[i].max(0.0)); + let lin = mirror_pow(c[i], EXPOSURE_LINEAR_GAMMA); let o = mirror_pow(lin * e + off, 1.0 / g); - effectcraft_color::linear_to_srgb(o.abs()).copysign(o) + mirror_pow(o, 1.0 / EXPOSURE_LINEAR_GAMMA) }) }); b @@ -333,6 +379,37 @@ pub fn mirror_pow(v: f32, p: f32) -> f32 { /// It used to offset the luminance by the HSL saturation, which is near 1 for any tinted /// near-black pixel: the faint colour noise of compressed footage's shadows came out as bright /// and black blocks (#1463), and 32-bpc overbrights divided by zero. +/// Black & White's Tint: the tint colour's hue and saturation at the gray's luminosity, as a +/// Color blend does (luminosity with weights 0.3 / 0.59 / 0.11, out-of-range channels pulled +/// back toward the gray). After Effects 2026 with Tint Color [1, 0.5, 0]: gray 0.1 (a red solid +/// at Reds 10, case 717) → (43, 21, 0), gray 0.6 (yellow at defaults, case 825) → (255, 129, 2), +/// white → white; the old hue / saturation / lightness mix gave (51, 26, 0) for the first. +/// The luminosity weights of [`bw_tint`] (a Color blend's). `fx_color.wgsl` repeats them; the GPU +/// test `shader_constants_match_the_cpu` checks that they are these. +pub const BW_TINT_LUMA: [f32; 3] = [0.3, 0.59, 0.11]; + +pub fn bw_tint(tint: [f32; 3], gray: f32) -> [f32; 3] { + let lum = |c: [f32; 3]| BW_TINT_LUMA[0] * c[0] + BW_TINT_LUMA[1] * c[1] + BW_TINT_LUMA[2] * c[2]; + if !gray.is_finite() || gray <= 0.0 { + return [0.0; 3]; + } + if gray >= 1.0 { + return [gray; 3]; + } + let d = gray - lum(tint); + let c = tint.map(|v| v + d); + let l = lum(c); + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + let mut o = c; + if n < 0.0 && l - n > 1e-9 { + o = o.map(|v| l + (v - l) * l / (l - n)); + } + if x > 1.0 && x - l > 1e-9 { + o = o.map(|v| l + (v - l) * (1.0 - l) / (x - l)); + } + o +} + fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let w = [ctx.params.f("reds"), ctx.params.f("yellows"), ctx.params.f("greens"), ctx.params.f("cyans"), ctx.params.f("blues"), ctx.params.f("magentas")] .map(|v| v as f32 / 100.0); @@ -347,13 +424,7 @@ fn black_white(ctx: &EffectCtx, mut b: Buf) -> Buf { let wt = w[i] + (w[(i + 1) % 6] - w[i]) * f; let (max, min) = (c[0].max(c[1]).max(c[2]), c[0].min(c[1]).min(c[2])); let gray = (min + (max - min) * wt).max(0.0); - if tint_on { - let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); - let (r, g, bl) = hsl_to_rgb(th, ts, gray.min(1.0)); - [r, g, bl] - } else { - [gray, gray, gray] - } + if tint_on { bw_tint([tc[0], tc[1], tc[2]], gray) } else { [gray, gray, gray] } }); b } @@ -418,15 +489,12 @@ fn fill(ctx: &EffectCtx, mut b: Buf) -> Buf { }); return b; } + // Without masks the layer becomes the fill colour, and Opacity is the layer's opacity: After + // Effects 2026 renders Fill (0, 0.5, 1) at Opacity 50 % as that colour at half alpha, with + // nothing of the original showing through (it is not a mix with the original). b.img.data.iter_mut().for_each(|px| { - let a = if invert { 1.0 - px[3] } else { px[3] }; - let f = [c[0] * a, c[1] * a, c[2] * a]; - for i in 0..3 { - px[i] += (f[i] - px[i]) * opacity; - } - if invert { - px[3] += (a - px[3]) * opacity; - } + let a = if invert { 1.0 - px[3] } else { px[3] } * opacity; + *px = [c[0] * a, c[1] * a, c[2] * a, a]; }); b } @@ -969,11 +1037,13 @@ pub fn specs() -> Vec { "Exposure", { let mut v = vec![p("channels", "Channels", Value::Enum(0), popup(&["Master", "Individual Channels"]))]; + // After Effects' ranges (valid, slider, decimals). Gamma values below 0.1 from + // older projects or Photoshop imports (0.01..9.99) are kept and still render. let group = |ids: [&'static str; 3]| { [ - p(ids[0], "Exposure", num(0.0), slider(-20.0, 20.0, -5.0, 5.0, 2)), + p(ids[0], "Exposure", num(0.0), slider(-100.0, 100.0, -4.0, 4.0, 2)), p(ids[1], "Offset", num(0.0), slider(-2.0, 2.0, -0.5, 0.5, 4)), - p(ids[2], "Gamma Correction", num(1.0), slider(0.01, 9.99, 0.1, 3.0, 2)), + p(ids[2], "Gamma Correction", num(1.0), slider(0.1, 10.0, 0.1, 10.0, 2)), ] }; v.extend(group(["master/exposure", "master/offset", "master/gamma"])); @@ -1173,6 +1243,17 @@ mod tests { } } + /// After Effects 2026 renders of full-frame solids with Tint at its defaults (sRGB working + /// space, not linearised, 32 bpc, 8-bit capture): Rec. 601 weights on the encoded values. + #[test] + fn tint_defaults_match_after_effects_greys() { + for (c, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let o = px("ec.color.tint", &[], [c[0], c[1], c[2], 1.0]); + let want = want / 255.0; + assert!(close(o, [want, want, want, 1.0], 1.0 / 255.0), "{c:?}: {o:?} vs {want}"); + } + } + #[test] fn colorama_reads_get_phase_from() { // Same intensity, different red: Intensity gives one colour, Red gives two. @@ -1227,6 +1308,75 @@ mod tests { assert!(go(0.0, 100.0, 64) >= 30); } + /// After Effects 2026: Tritone Highlights [1, 1, 0.8], Midtones [0.6, 0.2, 0.2], Shadows + /// [0, 0, 0.3]. On a red solid with Blend With Original 25 (case 718) AE gives (132, 23, 46); + /// on a grey ramp (case 815) levels 0, 63.9, 127.6, 191.4 give (0, 0, 77), (77, 26, 64), + /// (153, 51, 51), (204, 153, 128). + #[test] + fn tritone_matches_after_effects() { + let colours = [ + ("highlights", Value::Color([1.0, 1.0, 0.8, 1.0])), + ("midtones", Value::Color([0.6, 0.2, 0.2, 1.0])), + ("shadows", Value::Color([0.0, 0.0, 0.3, 1.0])), + ]; + let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.0); + let mut red = colours.to_vec(); + red.push(("blend", num(25.0))); + let o = px("ec.color.tritone", &red, [1.0, 0.0, 0.0, 1.0]); + assert!(near(o, [132.0, 23.0, 46.0]), "{:?}", o.map(|v| v * 255.0)); + for (level, ae) in [(0.1f32, [0.0, 0.0, 77.0]), (63.9, [77.0, 26.0, 64.0]), (127.6, [153.0, 51.0, 51.0]), (191.4, [204.0, 153.0, 128.0])] { + let v = level / 255.0; + let o = px("ec.color.tritone", &colours, [v, v, v, 1.0]); + assert!(near(o, ae), "level {level}: {:?} vs {ae:?}", o.map(|v| v * 255.0)); + } + } + + /// After Effects 2026, Tint Color [1, 0.5, 0]: a red solid at Reds 10 (case 717) and yellow + /// and white solids at the default sliders (case 825). + #[test] + fn black_white_tint_matches_after_effects() { + let tint = [("tint", Value::Bool(true)), ("tintColor", Value::Color([1.0, 0.5, 0.0, 1.0]))]; + let near = |o: [f32; 4], ae: [f32; 3]| (0..3).all(|i| (o[i] * 255.0 - ae[i]).abs() <= 1.5); + let mut reds = tint.to_vec(); + reds.push(("reds", num(10.0))); + let o = px("ec.color.blackwhite", &reds, [1.0, 0.0, 0.0, 1.0]); + assert!(near(o, [43.0, 21.0, 0.0]), "{:?}", o.map(|v| v * 255.0)); + let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 0.0, 1.0]); + assert!(near(o, [255.0, 129.0, 2.0]), "{:?}", o.map(|v| v * 255.0)); + let o = px("ec.color.blackwhite", &tint, [1.0, 1.0, 1.0, 1.0]); + assert!(near(o, [255.0, 255.0, 255.0]), "{:?}", o.map(|v| v * 255.0)); + // Hostile grays stay finite. + for g in [f32::NAN, -1.0, 0.0, 4.0] { + assert!(bw_tint([1.0, 0.5, 0.0], g).iter().all(|v| v.is_finite())); + } + } + + /// After Effects 2026 ramps (RENDERER round 2, session R8): negative Brightness and Contrast + /// are the inverses of the positive curves. (brightness, contrast, input, AE 8-bit) + #[test] + fn negative_brightness_and_contrast_invert_the_positive_curves() { + let go = |b: f64, c: f64, x: u8| { + let v = x as f32 / 255.0; + (px("ec.color.brightnesscontrast", &[("brightness", num(b)), ("contrast", num(c))], [v, v, v, 1.0])[0] * 255.0).round() as i32 + }; + let samples: &[(f64, f64, u8, u8)] = &[ + (-40.0, 0.0, 64, 50), + (-40.0, 0.0, 128, 99), + (-40.0, 0.0, 191, 155), + (-40.0, 0.0, 234, 207), + (0.0, -50.0, 21, 28), + (0.0, -50.0, 64, 76), + (0.0, -50.0, 191, 179), + (0.0, -50.0, 234, 227), + (40.0, 0.0, 128, 162), + (0.0, 50.0, 64, 52), + ]; + for &(b, c, x, ae) in samples { + let got = go(b, c, x); + assert!((got - ae as i32).abs() <= 7, "B{b} C{c} in {x}: got {got} AE {ae}"); + } + } + /// #1463: Black & White's sliders set the gray of each pure hue and blend between /// neighbouring hues; neutral pixels keep their value, overbright ones too, and an overbright /// colour stays finite (it used to divide by zero). @@ -1321,6 +1471,66 @@ mod tests { assert!((neg[0] + 0.5).abs() < 1e-4, "{neg:?}"); } + /// After Effects' Exposure ranges for all twelve sliders; a saved value outside them (a gamma + /// of 0.05, valid before, as in Photoshop) survives loading and still renders. + #[test] + fn exposure_has_after_effects_ranges_and_keeps_old_values() { + use effectcraft_project::build::Ids; + let spec = crate::find("ec.color.exposure").unwrap(); + let want = [(-100.0, 100.0, -4.0, 4.0, 2), (-2.0, 2.0, -0.5, 0.5, 4), (0.1, 10.0, 0.1, 10.0, 2)]; + let sliders: Vec<_> = spec + .params + .iter() + .filter_map(|p| match p.ui { + ParamUi::Slider { min, max, slider_min, slider_max, decimals } => Some((p.id, (min, max, slider_min, slider_max, decimals))), + _ => None, + }) + .collect(); + assert_eq!(sliders.len(), 12); + for (i, (id, r)) in sliders.iter().enumerate() { + assert_eq!(*r, want[i % 3], "{id}"); + } + let mut next = 1; + let mut g = crate::instantiate(spec, &mut Ids(&mut next), spec.name, [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + gamma.value = num(0.05); + gamma.ui = slider(0.01, 9.99, 0.1, 3.0, 2); + crate::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), [10.0, 10.0]); + let gamma = g.prop_mut("master/gamma").unwrap(); + assert_eq!(gamma.value, num(0.05)); + assert!(matches!(gamma.ui, ParamUi::Slider { min, .. } if min == 0.1)); + let o = px("ec.color.exposure", &[("master/gamma", num(0.05)), ("bypassLinearLight", Value::Bool(true))], [0.5, 0.5, 0.5, 1.0]); + assert!((o[0] - 0.5f32.powf(20.0)).abs() < 1e-9, "{o:?}"); + } + + /// Exposure on a red [1, 0, 0] solid against After Effects 2026 renders (sRGB working space, + /// not linearised, 32 bpc, 8-bit captures): the linear-light conversion is a pure 2.2 power. + /// The piecewise sRGB curve gave 2 / 7 / 39 for the three smallest Offsets. + #[test] + fn exposure_matches_after_effects_gamma_2_2() { + let red = [1.0, 0.0, 0.0, 1.0]; + let code = |o: [f32; 4], i: usize| o[i] * 255.0; + // Master Offset only: G and B carry the curve; R goes over 1 (255 in the 8-bit capture). + for (offset, ae) in [(0.0005, 8.0), (0.002, 15.0), (0.02, 43.0), (0.1, 90.0)] { + let o = px("ec.color.exposure", &[("master/offset", num(offset))], red); + for i in [1, 2] { + assert!((code(o, i) - ae).abs() <= 1.0, "offset {offset}: {} vs AE {ae}", code(o, i)); + } + assert!(o[0] >= 1.0, "{o:?}"); + } + // Exposure −2.25: AE 126; with Bypass Linear Light Conversion (gain on encoded values) 54. + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25))], red); + assert!((code(o, 0) - 126.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + let o = px("ec.color.exposure", &[("master/exposure", num(-2.25)), ("bypassLinearLight", Value::Bool(true))], red); + assert!((code(o, 0) - 54.0).abs() <= 1.0 && o[1] == 0.0, "{o:?}"); + // Default settings leave 32-bpc values alone, negative and over-range ones included. + let hdr = [-0.25, 0.5, 2.0, 1.0]; + assert!(close(px("ec.color.exposure", &[], hdr), hdr, 1e-5)); + // A negative Offset pushes black below zero, mirrored, rather than clipping or NaN. + let o = px("ec.color.exposure", &[("master/offset", num(-0.02))], [0.0, 0.0, 0.0, 1.0]); + assert!((o[0] + 0.02f32.powf(1.0 / 2.2)).abs() < 1e-5, "{o:?}"); + } + #[test] fn change_to_color_change_modes_tolerances_and_matte() { let red = [0.8, 0.1, 0.1, 1.0]; @@ -1462,6 +1672,30 @@ mod tests { assert_eq!(plain.get(10, 5), [1.0, 0.0, 0.0, 1.0]); } + /// After Effects 2026: Fill (0, 0.5, 1) at Opacity 50 % on an opaque layer (a Gradient Ramp + /// on a red solid) renders premultiplied (0, 0.251, 0.502) at alpha 0.502 everywhere: the + /// fill colour at half the layer's alpha, nothing of the ramp left. + #[test] + fn fill_opacity_is_the_layers_opacity_as_in_after_effects() { + let mut img = Image::new(16, 4); + for x in 0..16 { + let v = x as f32 / 15.0; + for y in 0..4 { + img.set(x, y, [v, 0.0, 0.0, 1.0]); + } + } + let vals = [("color", Value::Color([0.0, 0.5, 1.0, 1.0])), ("opacity", num(50.0))]; + let out = run_fx("ec.generate.fill", &vals, img, 0.0, EffectEnv::default()).img; + for x in [0, 7, 15] { + let p = out.get(x, 2); + let ae = [0.0, 0.251, 0.502, 0.502]; + assert!(p.iter().zip(ae).all(|(v, a)| (v - a).abs() < 1.0 / 255.0), "x {x}: {p:?} vs AE {ae:?}"); + } + // A half-transparent pixel ends at a quarter alpha, still the fill colour. + let half = run_fx("ec.generate.fill", &vals, Image::filled(2, 2, [0.2, 0.2, 0.2, 0.5]), 0.0, EffectEnv::default()).img; + assert_eq!(half.get(0, 0), [0.0, 0.125, 0.25, 0.25]); + } + #[test] fn colorama_palettes_modify_selection_masking_and_add_phase() { let grey = [0.5, 0.5, 0.5, 1.0]; diff --git a/crates/effects/src/crypto_select.rs b/crates/effects/src/crypto_select.rs new file mode 100644 index 000000000..19d93cdba --- /dev/null +++ b/crates/effects/src/crypto_select.rs @@ -0,0 +1,122 @@ +//! Editing a Cryptomatte effect's Selection text from a pick in the viewer (#473): replace it +//! with the picked ID, add it, or remove it, in the grammar `selection_hashes` reads. + +use crate::channel3d::{selection_hashes, selection_names}; + +/// How a pick changes the selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PickMode { + Replace, + Add, + Remove, +} + +/// A name as a selection token: quoted (with `\` escapes) when it has a comma, a quote or a +/// backslash, which the selection parser would otherwise split on or read as syntax. +fn quote(name: &str) -> String { + if !name.contains([',', '"', '\'', '\\']) { + return name.to_string(); + } + let mut out = String::from("\""); + for c in name.chars() { + if c == '"' || c == '\\' { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + +/// The selection token for the ID `hash` named `name` in `manifest`: the (quoted) name when it +/// reads back as exactly that ID, else ``. That covers unnamed IDs, names with `*` (which +/// would select by wildcard), names that look like ``, names with surrounding spaces (the +/// parser trims them) and names shared by several IDs. +pub fn token_for(name: Option<&str>, hash: u32, manifest: Option<&[(String, u32)]>) -> String { + match name { + Some(n) if !n.is_empty() && !n.contains('*') && selection_hashes("e(n), manifest) == [hash] => quote(n), + _ => format!("<{hash:08x}>"), + } +} + +/// `sel` edited by a pick of the ID `hash` whose token is `token` (see [`token_for`]). The text +/// stays as typed when the edit wouldn't change what it selects: Replace when it selects just +/// that ID, Add when it already selects it, Remove when no name or `` token selects it (a +/// wildcard that matches it stays). Other tokens (wildcards included) keep their text, +/// re-quoted where needed, separated by `, `. +pub fn edit_selection(sel: &str, token: &str, hash: u32, mode: PickMode, manifest: Option<&[(String, u32)]>) -> String { + let tokens: Vec = selection_names(sel).into_iter().filter(|t| !t.is_empty()).collect(); + let selects = |t: &String| !t.contains('*') && selection_hashes("e(t), manifest).contains(&hash); + let kept: Vec = match mode { + PickMode::Replace if selection_hashes(sel, manifest) == [hash] => return sel.to_string(), + PickMode::Replace => return token.to_string(), + PickMode::Add if selection_hashes(sel, manifest).contains(&hash) => return sel.to_string(), + PickMode::Add => tokens.iter().map(|t| quote(t)).chain([token.to_string()]).collect(), + PickMode::Remove if !tokens.iter().any(selects) => return sel.to_string(), + PickMode::Remove => tokens.iter().filter(|t| !selects(t)).map(|t| quote(t)).collect(), + }; + kept.join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_raster::channels3d::crypto_hash; + + fn manifest() -> Vec<(String, u32)> { + ["bunny", "Green, Glossy", r#"say "hi""#, r"back\slash", " pad ", "star*", "<0000abcd>", "twin"] + .iter() + .map(|n| (n.to_string(), crypto_hash(n))) + .chain([("twin".to_string(), 7)]) + .collect() + } + + /// Every name's token reads back as exactly its ID, through the effect's own parser. + #[test] + fn tokens_round_trip_through_selection_hashes() { + let m = manifest(); + for (n, h) in &m { + let t = token_for(Some(n), *h, Some(&m)); + assert_eq!(selection_hashes(&t, Some(&m)), [*h], "{n:?} → {t:?}"); + } + assert_eq!(token_for(Some("bunny"), crypto_hash("bunny"), Some(&m)), "bunny"); + assert_eq!(token_for(Some("Green, Glossy"), crypto_hash("Green, Glossy"), Some(&m)), r#""Green, Glossy""#); + assert_eq!(token_for(Some(r#"say "hi""#), crypto_hash(r#"say "hi""#), Some(&m)), r#""say \"hi\"""#); + // Wildcard-looking, hash-looking, empty, unnamed and shared names: the hash. + assert_eq!(token_for(Some("star*"), crypto_hash("star*"), Some(&m)), format!("<{:08x}>", crypto_hash("star*"))); + assert_eq!(token_for(Some("<0000abcd>"), crypto_hash("<0000abcd>"), Some(&m)), format!("<{:08x}>", crypto_hash("<0000abcd>"))); + assert_eq!(token_for(Some(""), 5, Some(&m)), "<00000005>"); + assert_eq!(token_for(Some(" pad "), crypto_hash(" pad "), Some(&m)), format!("<{:08x}>", crypto_hash(" pad "))); + assert_eq!(token_for(None, 0xdead_beef, None), ""); + assert_eq!(token_for(Some("twin"), 7, Some(&m)), "<00000007>", "the name selects the other twin"); + } + + #[test] + fn add_and_remove_keep_other_tokens_and_skip_duplicates() { + let m = manifest(); + let (b, g) = (crypto_hash("bunny"), crypto_hash("Green, Glossy")); + let tok = |n: &str| token_for(Some(n), crypto_hash(n), Some(&m)); + let edit = |sel: &str, n: &str, mode| edit_selection(sel, &tok(n), crypto_hash(n), mode, Some(&m)); + assert_eq!(edit("anything, else", "bunny", PickMode::Replace), "bunny"); + assert_eq!(edit(" bunny ", "bunny", PickMode::Replace), " bunny ", "same meaning: unchanged"); + assert_eq!(edit("", "bunny", PickMode::Add), "bunny"); + assert_eq!(edit("bunny", "Green, Glossy", PickMode::Add), r#"bunny, "Green, Glossy""#); + // Already selected: by name, by hash, by a wildcard. The text stays as typed. + assert_eq!(edit("bunny,floor", "bunny", PickMode::Add), "bunny,floor"); + assert_eq!(edit(&format!("<{b:08x}>"), "bunny", PickMode::Add), format!("<{b:08x}>")); + assert_eq!(edit("bun*", "bunny", PickMode::Add), "bun*"); + // Other tokens keep their meaning: quoted where needed, wildcards stay wildcards. + assert_eq!(edit(r#"'a, b', fl*, "x, *""#, "bunny", PickMode::Add), r#""a, b", fl*, "x, *", bunny"#); + assert_eq!(edit(r#""Green, Glossy", bunny"#, "bunny", PickMode::Remove), r#""Green, Glossy""#); + assert_eq!(edit(&format!("bunny, <{b:08x}>, x, bunny"), "bunny", PickMode::Remove), "x"); + assert_eq!(edit(&format!("<{g:08x}>"), "Green, Glossy", PickMode::Remove), ""); + // Not selected, or only by a wildcard: unchanged. + assert_eq!(edit("a ,b", "bunny", PickMode::Remove), "a ,b"); + assert_eq!(edit("bun*, x", "bunny", PickMode::Remove), "bun*, x"); + for sel in [r#""a, b", fl*, bunny"#, r#"x, "Green, Glossy", "say \"hi\"", "back\\slash""#] { + let before = selection_hashes(sel, Some(&m)); + let after = selection_hashes(&edit(sel, " pad ", PickMode::Add), Some(&m)); + assert_eq!(after, [before, vec![crypto_hash(" pad ")]].concat(), "{sel}"); + } + } +} diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index fd5a54123..637cc428e 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -112,10 +112,19 @@ pub(crate) fn gen_blend(o: Px, g: Px, mode: u32) -> Px { } } +/// 4-Color Gradient's weights are inverse squared distances softened by Blend²: each point +/// weighs 1 / (d² + Blend²) (pixels). Fitted to After Effects 2026 renders at Blend 10, 50, 100 +/// and 300 (power 2.00 at every Blend, softening 0 / 2367 / 9817 / 90127 ≈ Blend²; mean error +/// 0.27/255, max 0.9). Returns Blend² in buffer pixels, never 0. +pub fn four_color_softening(blend: f64, scale: f64) -> f64 { + let b = blend.max(0.0) * scale; + (b * b).max(1e-6) +} + fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let pts: Vec<((f64, f64), [f32; 4])> = (1..=4).map(|i| (b.to_px(ctx.params.v2(&format!("positionsColors/point{i}"))), ctx.params.color(&format!("positionsColors/color{i}")))).collect(); - let blend = ctx.params.f("blend").max(1.0); + let k = four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; let op = ctx.params.f("opacity") as f32 / 100.0; let mode = ctx.params.e("blendingMode"); @@ -126,7 +135,7 @@ fn four_color(ctx: &EffectCtx, mut b: Buf) -> Buf { let mut wsum = 0.0; for ((qx, qy), c) in &pts { let d2 = (x as f64 + 0.5 - qx).powi(2) + (y as f64 + 0.5 - qy).powi(2); - let w = 1.0 / (d2 / (blend * 100.0) + 1e-6).powf(1.5); + let w = 1.0 / (d2 + k); for i in 0..3 { acc[i] += c[i] as f64 * w; } @@ -423,6 +432,59 @@ mod generator_tests { } } + /// After Effects 2026, 4-Color Gradient defaults (yellow, green, magenta, blue at 10 % / 90 % + /// of a 1920 × 1080 layer) at Blend 100 and 300, here on a tenth-size layer with Blend 10 + /// and 30 (the weights scale with the layer): colours at AE pixels (10x + 5, 10y + 5). + #[test] + fn four_color_blend_matches_after_effects() { + let pts = [ + ("positionsColors/point1", [19.2, 10.8]), + ("positionsColors/point2", [172.8, 10.8]), + ("positionsColors/point3", [19.2, 97.2]), + ("positionsColors/point4", [172.8, 97.2]), + ]; + let cols = [ + ("positionsColors/color1", [1.0, 1.0, 0.0, 1.0]), + ("positionsColors/color2", [0.0, 1.0, 0.0, 1.0]), + ("positionsColors/color3", [1.0, 0.0, 1.0, 1.0]), + ("positionsColors/color4", [0.0, 0.0, 1.0, 1.0]), + ]; + let ae: [(f64, [(i64, i64, [f32; 3]); 5]); 2] = [ + ( + 10.0, + [ + (30, 20, [249.0, 240.0, 15.0]), + (150, 30, [20.0, 208.0, 47.0]), + (60, 90, [213.0, 56.0, 199.0]), + (100, 10, [115.0, 177.0, 78.0]), + (10, 50, [238.0, 145.0, 110.0]), + ], + ), + ( + 30.0, + [ + (30, 20, [236.0, 214.0, 41.0]), + (150, 30, [30.0, 189.0, 66.0]), + (60, 90, [203.0, 67.0, 188.0]), + (100, 10, [117.0, 173.0, 82.0]), + (10, 50, [233.0, 140.0, 115.0]), + ], + ), + ]; + for (blend, samples) in ae { + let mut vals: Vec<(&str, Value)> = pts.iter().map(|(k, v)| (*k, Value::Vec2(*v))).collect(); + vals.extend(cols.iter().map(|(k, v)| (*k, Value::Color(*v)))); + vals.push(("blend", num(blend))); + let o = run_fx("ec.generate.fourcolor", &vals, Image::filled(192, 108, [1.0, 0.0, 0.0, 1.0]), 0.0, EffectEnv::default()); + for (x, y, want) in samples { + let p = o.img.get(x, y); + for c in 0..3 { + assert!((p[c] * 255.0 - want[c]).abs() <= 3.0, "blend {blend} at ({x}, {y}) channel {c}: {} vs AE {}", p[c] * 255.0, want[c]); + } + } + } + } + #[test] fn four_color_none_stays_inside_the_layer_alpha() { // #1381: like the Gradient Ramp, the gradient fills a shape or text, not its bounding box. diff --git a/crates/effects/src/keying.rs b/crates/effects/src/keying.rs index 9d61ff8c8..4953d482c 100644 --- a/crates/effects/src/keying.rs +++ b/crates/effects/src/keying.rs @@ -2,12 +2,13 @@ //! matte clean-up. Written from public behaviour descriptions and the standard colour-difference / //! blue-screen matting literature (Vlahos; Smith & Blinn, "Blue Screen Matting"). -use effectcraft_color::{luminance, rgb_to_hsl, srgb_to_linear}; +use effectcraft_color::{rgb_to_hsl, srgb_to_linear}; use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use effectcraft_raster::{Image, gaussian_blur}; use rayon::prelude::*; +use crate::color_fx::luma_601; use crate::util::{Plane, gauss_plane, morph_frac, premul, unpremul}; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; @@ -135,46 +136,23 @@ fn color_key(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Luma Key ---- +/// Luma Key as After Effects 2026 keys a grey ramp (cases 812-814, 719): Key Out Brighter and +/// Darker cut hard at Threshold and ignore Tolerance; Key Out Similar removes, and Dissimilar +/// keeps, a hard band of Threshold ± Tolerance (levels of 255). Edge Thin and Edge Feather +/// then soften the cut. fn luma_key(ctx: &EffectCtx, mut b: Buf) -> Buf { let kind = ctx.params.e("keyType"); let thr = ctx.params.f("threshold") as f32 / 255.0; let tol = ctx.params.f("tolerance") as f32 / 255.0; let m = matte_of(&b.img, |c, _| { - let l = luminance(c[0], c[1], c[2]); - match kind { - 0 => { - if tol > 0.0 { - ((thr + tol - l) / tol).clamp(0.0, 1.0) - } else if l > thr { - 0.0 - } else { - 1.0 - } - } - 2 => { - if (l - thr).abs() <= tol { - 0.0 - } else { - 1.0 - } - } - 3 => { - if (l - thr).abs() <= tol { - 1.0 - } else { - 0.0 - } - } - _ => { - if tol > 0.0 { - ((l - (thr - tol)) / tol).clamp(0.0, 1.0) - } else if l < thr { - 0.0 - } else { - 1.0 - } - } - } + let l = luma_601([c[0], c[1], c[2]]); + let keep = match kind { + 0 => l <= thr, + 2 => (l - thr).abs() > tol, + 3 => (l - thr).abs() <= tol, + _ => l >= thr, + }; + if keep { 1.0 } else { 0.0 } }); let m = thin_feather(m, ctx.params.f("edgeThin") * b.scale, ctx.params.f("edgeFeather") * b.scale); apply_matte(&mut b.img, &m, 0); @@ -334,7 +312,7 @@ fn extract(ctx: &EffectCtx, mut b: Buf) -> Buf { 2 => c[1], 3 => c[2], 4 => a, - _ => luminance(c[0], c[1], c[2]), + _ => luma_601([c[0], c[1], c[2]]), }; let mut k = 1.0f32; if v < bp { @@ -802,6 +780,68 @@ mod tests { assert_eq!(out.get(1, 0)[3], 1.0); } + /// After Effects 2026 (cases 812-814): Luma Key on a vertical black-to-white ramp (row y + /// has level (y + 0.5) / 1080 × 255), Threshold 140, Tolerance 20. EffectCraft ramped the + /// matte over the tolerance (case 719: a 50 % grey kept 80 % of its alpha where AE keys it + /// out). + #[test] + fn luma_key_matches_after_effects() { + let ramp = || { + let mut img = Image::new(1, 1080); + for y in 0..1080 { + let v = (y as f32 + 0.5) / 1080.0; + img.set(0, y, [v, v, v, 1.0]); + } + img + }; + let key = |kind: u32, thin: f64, feather: f64| { + let set = + [("keyType", Value::Enum(kind)), ("threshold", num(140.0)), ("tolerance", num(20.0)), ("edgeThin", num(thin)), ("edgeFeather", num(feather))]; + let o = run("ec.key.luma", &set, ramp()); + (0..1080).map(|y| o.get(0, y)[3]).collect::>() + }; + // Key Out Darker (AE 2 = EffectCraft 1): keyed up to row 592, kept from 593. + let d = key(1, 0.0, 0.0); + assert!(d[..593].iter().all(|a| *a == 0.0) && d[593..].iter().all(|a| *a == 1.0), "{:?}", &d[588..598]); + // Key Out Similar: AE keys rows 509..=677 (levels 120.3..160.0) and keeps the rest; the + // rows at the band's ends sit within 0.1 level of 120 / 160, so they may go either way. + let s = key(2, 0.0, 0.0); + assert!( + (0..1080).filter(|y| !(507..=510).contains(y) && !(676..=679).contains(y)).all(|y| (s[y] == 0.0) == (509..=677).contains(&y)), + "{:?} {:?}", + &s[505..512], + &s[674..681] + ); + // Key Out Brighter and Dissimilar are the complements. + let b = key(0, 0.0, 0.0); + assert!(b[595] == 0.0 && b[590] == 1.0); + let ds = key(3, 0.0, 0.0); + assert!(ds[600] == 1.0 && ds[400] == 0.0 && ds[700] == 0.0); + // A 50 % grey at Darker 140 / 20 is keyed out entirely (case 719). + let o = + run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(140.0)), ("tolerance", num(20.0))], Image::filled(2, 2, [0.5, 0.5, 0.5, 1.0])); + assert_eq!(o.get(0, 0)[3], 0.0); + // Edge Thin 1, Edge Feather 2.5 (case 814): AE's alpha rises over rows 591..596. + let f = key(1, 1.0, 2.5); + for (y, ae) in [(590, 0.0), (591, 0.026), (592, 0.139), (593, 0.357), (594, 0.635), (595, 0.868), (596, 0.97), (597, 1.0)] { + assert!((f[y] - ae).abs() < 0.2, "row {y}: {} vs AE {ae}", f[y]); + } + } + + /// After Effects 2026: Key Out Darker and Extract's Black Point at 65 / 166 / 24 on pure + /// red / green / blue keep red and blue and key out green: Rec. 601 luminance (Rec. 709 + /// would key out red and blue and keep green). + #[test] + fn luma_key_and_extract_use_rec601_as_after_effects() { + for (c, thr, kept) in [([1.0, 0.0, 0.0], 65.0, true), ([0.0, 1.0, 0.0], 166.0, false), ([0.0, 0.0, 1.0], 24.0, true)] { + let img = Image::filled(2, 1, [c[0], c[1], c[2], 1.0]); + let key = run("ec.key.luma", &[("keyType", Value::Enum(1)), ("threshold", num(thr))], img.clone()); + let ext = run("ec.key.extract", &[("blackPoint", num(thr))], img); + let want = if kept { 1.0 } else { 0.0 }; + assert_eq!((key.get(0, 0)[3], ext.get(0, 0)[3]), (want, want), "{c:?} at {thr}"); + } + } + #[test] fn extract_keeps_range() { let mut img = Image::new(3, 1); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 40ed79a7d..88a46f1eb 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -21,6 +21,7 @@ mod color2; mod color3; mod color_fx; pub mod controls; +pub mod crypto_select; mod distort; mod distort2; mod distort3; @@ -70,11 +71,11 @@ use std::collections::HashMap; use std::sync::OnceLock; pub use card3d::{CompLight, CompScene}; -pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color_fx::{ - HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, fill_uses_masks, huesat_ranges_identity, - levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, + BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, HUESAT_CHANNELS, HUESAT_RANGES, LEVELS_CHANNELS, LUMA_601, PHOTO_FILTER_CUSTOM, PHOTO_FILTERS, exposure_settings, + fill_uses_masks, huesat_ranges_identity, levels_channel_ids, levels_channel_settings, levels_channels_identity, levels_clip, }; +pub use color_fx::{COLORAMA_PRESETS, ColoramaPalette}; pub use color2::{Curve, auto_correct_settings, color_link_mode, color_link_sample, equalize_tables, luma_clip_points, shadow_highlight_amounts}; pub use color3::OffsetCurve; pub use distort::transform_shutter; @@ -91,7 +92,7 @@ pub use sim3::{PgBlit, PgPlan, playground_plan, playground_state}; pub use stylize2::strobe_on; // CPU helpers the GPU kernels share (effectcraft-gpu). pub use blur2::{camera_lens_plain, camera_lens_spans}; -pub use generate::gen_mode; +pub use generate::{four_color_softening, gen_mode}; pub use generate2::pattern_kind as cell_pattern_kind; pub use generate2::{BoltSeg, Contour, Ghost, RadioPlan, Wave, flare_ghosts, lightning_segments, radio_plan}; pub use generate3::{ @@ -110,7 +111,7 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, // effectcraft-gpu fx_tone. pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). -pub use channel3d::{CRYPTO_LAYERS, crypto_aux_image, id_color, selection_hashes}; +pub use channel3d::{CRYPTO_LAYERS, aux_point, crypto_aux_image, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ @@ -297,6 +298,43 @@ impl Buf { } } +/// Motion Tile's output frame for a buffer with layer origin `offset` (pixels) at `scale`: +/// `(width, height, offset)`. Output Width / Height grow (or crop) the layer rectangle about its +/// centre, on adjustment layers too, where the rectangle is the comp's and starts at the comp +/// origin in the buffer below (After Effects; a Transform after it brings the tiles into view, +/// #1727). An adjustment at 100 % keeps the buffer it was given; one whose frame would pass +/// 16 384 pixels a side does too. +pub fn motion_tile_frame(ctx: &EffectCtx, offset: [f64; 2], size: (u32, u32), scale: f64) -> (u32, u32, [f64; 2]) { + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let (pw, ph) = (ctx.params.f("outputWidth"), ctx.params.f("outputHeight")); + let ow = (lw * pw / 100.0).max(1.0 / scale); + let oh = (lh * ph / 100.0).max(1.0 / scale); + let (nw, nh) = ((ow * scale).round().max(1.0), (oh * scale).round().max(1.0)); + let grow = [(ow - lw) * 0.5 * scale, (oh - lh) * 0.5 * scale]; + if !ctx.adjustment { + return (nw as u32, nh as u32, grow); + } + // The grown rectangle's top-left in the buffer below, on whole pixels so the result lines + // up with the comp again when the stack ends. + let (x0, y0) = ((offset[0] - grow[0]).round(), (offset[1] - grow[1]).round()); + let fits = [x0, y0, nw, nh].iter().all(|v| v.is_finite()) && nw <= 16384.0 && nh <= 16384.0; + if (pw == 100.0 && ph == 100.0) || !fits { + return (size.0, size.1, offset); + } + (nw as u32, nh as u32, [offset[0] - x0, offset[1] - y0]) +} + +/// Where an adjustment stack's result (comp origin at `offset`, `size` pixels) lies on the canvas +/// it came from (comp origin at `canvas_offset`): the result pixel at canvas pixel (0, 0). +/// `None` when it already is the canvas, frame for frame. +pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u32), canvas: (u32, u32)) -> Option<(i64, i64)> { + let (dx, dy) = ((offset[0] - canvas_offset[0]).round(), (offset[1] - canvas_offset[1]).round()); + if !dx.is_finite() || !dy.is_finite() || (dx == 0.0 && dy == 0.0 && size == canvas) { + return None; + } + Some((dx as i64, dy as i64)) +} + /// Where [`Buf::cover`] would take a buffer: shift its pixels by `(dx, dy)` and resize it to /// `width` × `height` so it spans the `size`-layer-pixel region that starts at its offset — the /// whole layer rectangle, which a sized source's buffer already is. `None` when the buffer @@ -406,6 +444,8 @@ pub struct EffectEnv<'a> { pub working_space: Option, /// Working-space pixels are linear light (Linearize Working Space). pub working_linear: bool, + /// The project works in 32 bpc float (Levels' "Off for 32 bpc Color" clips only below it). + pub float_depth: bool, /// The composition's shutter (angle and phase in degrees, samples per frame) when both the /// comp's and the layer's motion blur switches are on (Transform's Use Composition's /// Shutter Angle). diff --git a/crates/effects/src/noise3.rs b/crates/effects/src/noise3.rs index 88a5c01d5..39980dd80 100644 --- a/crates/effects/src/noise3.rs +++ b/crates/effects/src/noise3.rs @@ -17,7 +17,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::ParamUi; use rayon::prelude::*; -use crate::generate::{fade, lattice}; +use crate::generate::lattice; use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, num, p, popup, slider}; /// Fractal Type options (shared by both effects). @@ -139,7 +139,7 @@ fn bspline(t: f32) -> [f32; 3] { } /// 3D value noise in about 0..1 with the given [`NOISE_TYPES`] interpolation in x and y (the -/// evolution axis z always fades smoothly). +/// evolution axis z always interpolates linearly, so evolution moves at a constant rate). /// /// Soft Linear is linear interpolation softened by a one-cell box filter, which is the quadratic /// B-spline: smooth like Spline from 9 lattice values instead of 16. A fade between two values @@ -149,7 +149,7 @@ pub fn typed_noise(x: f32, y: f32, z: f32, seed: u32, noise_type: u32) -> f32 { // Soft Linear's taps are centred on the nearest lattice point, the others start at the cell's. let h = if noise_type == NOISE_SOFT_LINEAR { 0.5 } else { 0.0 }; let (x0, y0, z0) = ((x + h).floor(), (y + h).floor(), z.floor()); - let (tx, ty, fz) = (x + h - x0, y + h - y0, fade(z - z0)); + let (tx, ty, fz) = (x + h - x0, y + h - y0, z - z0); let (ix, iy, iz) = (x0 as i32, y0 as i32, z0 as i32); let l = |a: f32, b: f32, t: f32| a + (b - a) * t; let slice = |dz: i32| -> f32 { @@ -476,6 +476,20 @@ mod tests { run_fx(id, vals, Image::filled(40, 24, [0.2, 0.4, 0.6, 1.0]), 0.0, EffectEnv::default()).img } + /// Evolution moves the noise at a constant rate: no ease in or out within a revolution. + #[test] + fn evolution_is_linear() { + for noise_type in [0, 1, 2, 3] { + let a = typed_noise(0.3, 0.7, 0.0, 5, noise_type); + let b = typed_noise(0.3, 0.7, 1.0, 5, noise_type); + for z in [0.25, 0.5, 0.75] { + let want = a + (b - a) * z; + let got = typed_noise(0.3, 0.7, z, 5, noise_type); + assert!((got - want).abs() < 1e-4, "type {noise_type} z {z}: {got} vs {want}"); + } + } + } + /// A Fractal Noise saved before the twirl-downs existed: flat ids move into their groups and /// the new controls take values that keep the old look. #[test] diff --git a/crates/effects/src/stylize2.rs b/crates/effects/src/stylize2.rs index b957b4e41..e0354ea75 100644 --- a/crates/effects/src/stylize2.rs +++ b/crates/effects/src/stylize2.rs @@ -433,13 +433,7 @@ fn motion_tile(ctx: &EffectCtx, b: Buf) -> Buf { let mirror = ctx.params.b("mirrorEdges"); let phase = ctx.params.f("phase") / 360.0; let hshift = ctx.params.b("horizontalPhaseShift"); - let (nw, nh, noff) = if ctx.adjustment { - (b.img.width, b.img.height, b.offset) - } else { - let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); - let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); - ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) - }; + let (nw, nh, noff) = crate::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s); let mut out = Image::new(nw, nh); let src = &b.img; out.rows_mut().for_each(|(y, row)| { diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index ce773e545..4f263b6fb 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -186,6 +186,101 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { Ok(json!({"prop": uid, "color": color})) } +/// What a Cryptomatte effect has at a layer-space point: the ID with the most coverage in its +/// Cryptomatte layer, with the aux channels (for the manifest) and the effect's Selection. +struct CryptoHit { + at: (effectcraft_project::ItemId, LayerId, Tick), + aux: std::sync::Arc, + layer: String, + hash: u32, + selection: String, + prop: Uid, + /// The Selection has an expression: setting its value wouldn't change what it reads. + driven: bool, +} + +impl CryptoHit { + fn manifest(&self) -> Option<&[(String, u32)]> { + self.aux.manifests.iter().find(|(l, _)| *l == self.layer).map(|(_, m)| m.as_slice()) + } + fn reply(&self, sel: &str) -> Value { + let selected = effectcraft_effects::selection_hashes(sel, self.manifest()).contains(&self.hash); + let name = self.aux.manifest_name(&self.layer, self.hash); + json!({"name": name, "hash": format!("{:08x}", self.hash), "layer": self.layer, "selected": selected}) + } +} + +/// The ID under `{x, y}` (effect space, which is layer space on footage and precomp layers) for +/// the Cryptomatte effect `{layer?, effect?}`, from the aux channels its render reads. The point +/// snaps to the centre of the pixel the effect's input buffer samples in a render at `scale` +/// (the viewer's resolution). +fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result> { + let (cid, lid, fx_uid) = find_fx(s, p, cmd)?; + let (Some(x), Some(y)) = (f_p(p, "x"), f_p(p, "y")) else { return Err(bad(cmd, "missing `x` / `y` (layer pixels)")) }; + let scale = f_p(p, "scale").unwrap_or(1.0); + if !(scale.is_finite() && scale > 0.0 && scale <= 1.0) { + return Err(bad(cmd, "`scale` is the preview's resolution factor, above 0 and at most 1")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; + let fx = layer.effects().ok_or_else(|| bad(cmd, "no effects"))?; + let pos = fx.groups().position(|g| g.uid == fx_uid).ok_or_else(|| bad(cmd, "no such effect"))?; + let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; + let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; + let t = super::time_p(s, p, Some(comp)); + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); + let r = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }); + let aux = r.layer_aux(&ctx, layer); + let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); + let Some((aux, layer_name)) = aux.and_then(|a| a.resolve_crypto_layer(want).map(|l| (a, l))) else { + return Err(bad(cmd, "this layer has no Cryptomatte channels (a multi-layer OpenEXR with Cryptomatte, or a 3D precomp)")); + }; + // The grid of the effect's input buffer (footage keeps native pixels from 3/4 scale up, + // proxies have their own size, earlier effects can pad it), rendered and cached. + let b = r.layer_input(&ctx, layer, pos).ok_or_else(|| bad(cmd, "this layer has no pixels"))?; + let (bs, o) = (b.scale, b.offset); + // The centre of its pixel under the point, at the aux point the effect reads there. + let [ax, ay] = effectcraft_effects::aux_point(bs, o, [(x * bs + o[0]).floor() + 0.5, (y * bs + o[1]).floor() + 0.5]); + let Some(hash) = aux.index_at(ax, ay).and_then(|i| aux.crypto_id_at(&layer_name, i)) else { return Ok(None) }; + let (selection, driven) = (params.s("selection").to_string(), prop.expr.as_ref().is_some_and(|e| e.enabled)); + Ok(Some(CryptoHit { at: (cid, lid, t), aux, layer: layer_name, hash, selection, prop: prop.uid, driven })) +} + +/// `effect.cryptomatteAt`: the ID under a point, for the viewer's hover hint. The first call per +/// frame renders the effect's input to learn its pixel grid (cached afterwards). +fn crypto_at(s: &mut Session, p: &Value) -> Result { + Ok(crypto_hit(s, p, "effect.cryptomatteAt")?.map_or(Value::Null, |h| h.reply(&h.selection))) +} + +/// The Cryptomatte eyedropper: replace the effect's Selection with the ID under a point, add it +/// or remove it, in one undo step at the time it was read (none when its meaning stays). +fn pick_crypto(s: &mut Session, p: &Value) -> Result { + use effectcraft_effects::crypto_select::{PickMode, edit_selection, token_for}; + let cmd = "effect.pickCryptomatte"; + let mode = match str_p(p, "mode").unwrap_or("replace") { + "replace" => PickMode::Replace, + "add" => PickMode::Add, + "remove" => PickMode::Remove, + m => return Err(bad(cmd, format!("unknown `mode` {m:?} (replace, add or remove)"))), + }; + let h = crypto_hit(s, p, cmd)?.ok_or_else(|| bad(cmd, "no Cryptomatte ID there (empty or outside the layer)"))?; + if h.driven { + return Err(bad(cmd, "the Selection is driven by an expression; disable it to pick")); + } + let m = h.manifest(); + let sel = edit_selection(&h.selection, &token_for(h.aux.manifest_name(&h.layer, h.hash), h.hash, m), h.hash, mode, m); + let changed = sel != h.selection; + if changed { + let (cid, lid, t) = h.at; + s.execute("prop.set", json!({"comp": cid.0, "layer": lid.0, "prop": h.prop, "value": sel, "time": t.seconds(), "timeBase": "comp"}))?; + } + let mut out = h.reply(&sel); + out["selection"] = json!(sel); + out["changed"] = json!(changed); + Ok(out) +} + fn remove(s: &mut Session, p: &Value) -> Result { let cid = super::comp_id(s, p)?; let fx = effects_p(s, p, "effect.remove")?; @@ -632,6 +727,21 @@ pub fn specs() -> Vec { has_layers, pick_color ), + cmd!( + "effect.pickCryptomatte", + "Pick Cryptomatte", + [], + None, + "{layer?, effect?: index|uid|name, x, y (effect space), mode?: replace|add|remove (default replace), scale? (preview resolution, default 1), time?} → {name, hash, layer, selection, changed, selected} — edits the Cryptomatte effect's Selection with the ID under the point", + has_layers, + pick_crypto + ), + crate::query!( + "effect.cryptomatteAt", + "Cryptomatte At", + "{layer?, effect?: index|uid|name, x, y (effect space), scale?, time?} → {name, hash, layer, selected} | null — the Cryptomatte ID under the point", + crypto_at + ), cmd!("effect.applyLast", "Last Effect", ["Effect"], Some("Cmd+Alt+Shift+E"), "{layers?}", has_layers, last), cmd!("effect.removeAll", "Remove All", ["Effect"], Some("Cmd+Shift+E"), "{layers?}", has_layers, remove_all), cmd!("effect.remove", "Remove Effect", [], None, "{layer?, effect: index|uid|name}", has_layers, remove), diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 8c4c384ea..0fea452dc 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -665,6 +665,15 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t) .ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?; } + if let Some(f) = p.get("frameStart") { + // Frame Count: After Effects offers Start at 0 and Start at 1 only. Scripts and dialog + // number fields may send `1.0`. + proj.settings.frame_start = match f.as_f64() { + Some(0.0) => 0, + Some(1.0) => 1, + _ => return Err(bad(cmd, format!("frameStart: 0|1, not `{f}`"))), + }; + } Ok(()) })?; Ok(serde_json::to_value(&s.project.settings).unwrap_or_default()) @@ -745,7 +754,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", + "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16, frameStart?: 0|1 (Frame Count: Start at 0 / Start at 1)}", always, project_settings ), diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 3af877d34..00232967e 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -22,8 +22,11 @@ pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Lay }); } let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?; - let pr = layer.props.prop(path).ok_or_else(|| { - let group = layer.props.group(path).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid))); + // `effects/[#n]/` is how `essential.list`, presets and Copy write an + // effect property; turn it into the uid path `PropGroup::prop` parses (#2064). + let resolved = super::resolve_effect_path(layer, path); + let pr = layer.props.prop(&resolved).ok_or_else(|| { + let group = layer.props.group(&resolved).or_else(|| path.trim().strip_prefix('@')?.parse().ok().and_then(|uid| layer.props.find_group(uid))); if group.is_some() { bad(cmd, format!("`{path}` is a property group; use layer.tree (MCP: get_layer) to inspect its children, then select a leaf property")) } else { @@ -179,7 +182,9 @@ pub(crate) fn key_time(layer: &Layer, frame_rate: FrameRate, p: &Value, param: & pub(crate) fn key_timing(layer: &Layer, t: Tick) -> Value { let ct = layer.comp_time(t); let mut out = json!({"time": t.seconds(), "layerTime": t.seconds(), "compTime": ct.seconds()}); - if !layer.is_active_at(ct) { + // A key exactly on the out point ends a fade normally: it is outside the half-open visible + // range, but it is not a caller mistake, so it does not warn (#2084). + if ct < layer.in_point || ct > layer.out_point { out["warning"] = json!(format!( "Keyframe at comp time {} s is outside the layer's active range [{} s, {} s).", ct.seconds(), diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 8cb3071bf..556c02bcb 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -28,6 +28,20 @@ pub fn display_time(s: &Session, comp: &Comp, t: Tick) -> String { } } +/// A length (a layer's Duration) in the project's Time Display Style: a count of frames, so +/// neither Start Numbering Frames At nor the comp's Start Timecode moves it. +pub fn display_duration(s: &Session, comp: &Comp, len: Tick) -> String { + let fr = comp.frame_rate; + let n = fr.frame_at(len); + match s.project.settings.time_display { + TimeDisplayStyle::Frames => format!("{n:05}"), + TimeDisplayStyle::Feet35 | TimeDisplayStyle::Feet16 => { + effectcraft_time::format_feet_frames(n, s.project.settings.time_display.frames_per_foot().unwrap_or(16)) + } + TimeDisplayStyle::Timecode => effectcraft_time::format_timecode_ae(n, fr, false), + } +} + /// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame /// `current`) into a comp frame. pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 96b3bf67d..08ba7420e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1036,12 +1036,18 @@ impl Session { /// Render a comp frame with the session's footage + expression hosts. pub fn render(&self, comp: ItemId, t: Tick, opts: RenderOpts) -> Image { + self.renderer(opts).comp_frame(comp, t) + } + + /// The renderer [`Session::render`] uses: the session's footage, expressions, layer cache and + /// accelerator, and the render prefs (nested switches, draft shadows) over `opts`. + pub fn renderer(&self, opts: RenderOpts) -> Renderer<'_> { let opts = RenderOpts { nested_switches: self.prefs.general.switches_affect_nested_comps, draft_shadows: self.prefs.three_d.realtime_shadows, ..opts }; let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts); r.expr = self.expr.as_deref(); r.cache = Some(&self.layer_cache); r.accel = self.accel.as_deref(); - r.comp_frame(comp, t) + r } /// Resolve a comp reference (item id number or name; `None`/null = the active comp). @@ -1242,6 +1248,8 @@ mod tests_codec_options; #[cfg(test)] mod tests_color_view; #[cfg(test)] +mod tests_crypto_pick; +#[cfg(test)] mod tests_delete_keys; #[cfg(test)] mod tests_disk_cache; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index bc486a0c5..aa2e4ffe1 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -733,6 +733,7 @@ Layer Shape Layer | layer.newShape Adjustment Layer | layer.newAdjustment Content-Aware Fill Layer... | layer.newContentAwareFill + 3D Primitive... | layer.newPrimitive Cube | layer.new3dPrimitive {"kind":"cube"} Sphere | layer.new3dPrimitive {"kind":"sphere"} Plane | layer.new3dPrimitive {"kind":"plane"} diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index ef3e748a6..0b457926c 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -116,6 +116,11 @@ impl Session { self.layer_cache.set_disk(None); return; }; + // Tests: the Roto/Object Matte segment store below is one per process, so a test that + // opens a disk cache (often the platform folder, through a settings store) would take + // over the store of a roto test running beside it. Serialise them with the roto tests. + #[cfg(test)] + crate::tests_roto::hold_roto_cache_test_lock(); let dc = match &self.disk_cache { Some(c) if c.folder() == folder.as_path() => { c.set_max_bytes(max); diff --git a/crates/engine/src/tests_crypto_pick.rs b/crates/engine/src/tests_crypto_pick.rs new file mode 100644 index 000000000..799027c7e --- /dev/null +++ b/crates/engine/src/tests_crypto_pick.rs @@ -0,0 +1,336 @@ +//! `effect.pickCryptomatte` / `effect.cryptomatteAt` (#473): the Cryptomatte eyedropper reads the +//! aux channels the effect renders from (footage at its source time, a 3D precomp's generated +//! pass) and edits the Selection text in one undo step. + +use std::sync::Arc; + +use effectcraft_project::{Footage, FootageKind, ItemId}; +use effectcraft_raster::channels3d::{crypto_float, crypto_hash}; +use effectcraft_raster::{AuxChannels, Image}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::render::FootageSource; + +const COMMA: &str = "Green, \"Glossy\""; + +/// 8 × 4 footage with a 16 × 8 Cryptomatte (two aux pixels per layer pixel). Layer pixels in x: +/// 0-1 `left` (0.6) over `right` (0.4); 2-3 `left` (0.3) under the comma name (0.7), ranks out of +/// order; 4-5 `star*`; 6 an ID missing from the manifest; 7 a 0.5 / 0.5 tie of `left` and +/// `right`. Rows 3+ are empty. From 1 s of source time `late` replaces columns 0-1. +struct Crypto; + +fn aux_at(t: Tick) -> AuxChannels { + let (w, h) = (16usize, 8usize); + let mut planes = vec![vec![0.0f32; w * h]; 4]; + for (i, x) in (0..w * h).map(|i| (i, i % w)).filter(|(i, _)| i / w < 6) { + let late = if t >= Tick::from_seconds_f64(1.0) { "late" } else { "left" }; + let (a, ca, b, cb) = match x { + 0..4 => (late, 0.6, "right", 0.4), + 4..8 => ("left", 0.3, COMMA, 0.7), + 8..12 => ("star*", 1.0, "", 0.0), + 12..14 => ("ghost", 1.0, "", 0.0), + _ => ("left", 0.5, "right", 0.5), + }; + for (k, v) in [crypto_float(crypto_hash(a)), ca, crypto_float(crypto_hash(b)), cb].into_iter().enumerate() { + planes[k][i] = v; + } + } + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + for (k, c) in ["R", "G", "B", "A"].iter().enumerate() { + aux.channels.push((format!("CryptoObject00.{c}"), planes[k].clone())); + } + aux.manifests = vec![("CryptoObject".into(), ["left", "right", "late", COMMA, "star*"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())]; + aux +} + +impl FootageSource for Crypto { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width, f.height))) + } + fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + Some(Arc::new(aux_at(t))) + } +} + +struct Probe; +impl crate::Importer for Probe { + fn probe(&self, path: &str) -> Result { + let duration = Tick::from_seconds_f64(2.0); + // A half-size proxy of the EXR. + let (width, height) = if path.contains("proxy") { (4, 2) } else { (8, 4) }; + Ok(Footage { + path: path.into(), + kind: FootageKind::Video, + width, + height, + has_video: true, + frame_rate: FrameRate::from_f64(24.0), + duration, + ..Default::default() + }) + } +} + +fn session() -> Session { + let mut s = Session { footage: Arc::new(Crypto), importer: Some(Arc::new(Probe)), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s +} + +fn pick(s: &mut Session, x: f64, mode: &str) -> Value { + s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1.5, "mode": mode})).unwrap() +} + +fn selection(s: &Session) -> String { + let l = &s.active_comp().unwrap().layers[0]; + let g = l.effects().unwrap().groups().next().unwrap(); + match &g.prop("selection").unwrap().value { + effectcraft_keyframe::Value::Str(t) => t.clone(), + v => panic!("{v:?}"), + } +} + +#[test] +fn picks_replace_add_and_remove_in_one_undo_step_each() { + let mut s = session(); + let steps = s.history.undo.len(); + let r = pick(&mut s, 0.5, "replace"); + assert_eq!((r["name"].as_str(), r["selection"].as_str(), r["changed"].as_bool()), (Some("left"), Some("left"), Some(true)), "{r}"); + assert_eq!(r["hash"], format!("{:08x}", crypto_hash("left"))); + assert_eq!(s.history.undo.len(), steps + 1); + // The highest coverage wins even when it is not rank 0; the name is quoted. + assert_eq!(pick(&mut s, 2.5, "add")["selection"], r#"left, "Green, \"Glossy\"""#); + // Adding it again changes nothing and leaves no undo step. + let again = pick(&mut s, 3.5, "add"); + assert_eq!((again["changed"].as_bool(), s.history.undo.len()), (Some(false), steps + 2)); + // A `*` name and an unnamed ID go in as hashes; a tie goes to rank 0. + pick(&mut s, 4.5, "add"); + assert_eq!(pick(&mut s, 6.5, "add")["selection"], format!(r#"left, "Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost"))); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 7.5, "y": 0.5})).unwrap()["name"], "left"); + let r = pick(&mut s, 0.0, "remove"); + assert_eq!(r["selection"], format!(r#""Green, \"Glossy\"", <{:08x}>, <{:08x}>"#, crypto_hash("star*"), crypto_hash("ghost"))); + assert_eq!(r["selected"], false); + // The text parses back to exactly the picked IDs. + let manifest = aux_at(Tick::ZERO).manifests[0].1.clone(); + let got = effectcraft_effects::selection_hashes(&selection(&s), Some(&manifest)); + assert_eq!(got, [COMMA, "star*", "ghost"].map(crypto_hash)); + assert_eq!(s.history.undo.len(), steps + 5); + s.execute("edit.undo", json!({})).unwrap(); + assert!(selection(&s).starts_with("left, "), "{}", selection(&s)); + pick(&mut s, 2.0, "replace"); + assert_eq!(selection(&s), r#""Green, \"Glossy\"""#); + // The same selection with other spacing: no change, no undo step. + s.execute_checked("prop.set", json!({"path": "effects/#1/selection", "value": " \"Green, \\\"Glossy\\\"\" "})).unwrap(); + let n = s.history.undo.len(); + assert_eq!((pick(&mut s, 2.0, "replace")["changed"].as_bool(), s.history.undo.len()), (Some(false), n)); +} + +#[test] +fn bad_points_layers_and_modes_are_errors_and_change_nothing() { + let mut s = session(); + let at = |s: &mut Session, x: f64, y: f64| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": y})); + for (x, y) in [(-0.1, 1.0), (8.0, 1.0), (1.0, 4.0), (1.0, 3.5), (1e300, -1e300)] { + assert!(at(&mut s, x, y).is_err(), "{x},{y}"); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": x, "y": y})).unwrap(), Value::Null); + } + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "mode": "toggle"})).is_err()); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1})).is_err()); + for scale in [0.0, -1.0, 2.0] { + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1, "scale": scale})).is_err(), "{scale}"); + } + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 2, "x": 1, "y": 1})).is_err(), "not a Cryptomatte effect"); + assert_eq!(selection(&s), ""); + // A layer without aux channels. + s.execute("layer.newSolid", json!({"name": "S"})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let e = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).unwrap_err().to_string(); + assert!(e.contains("no Cryptomatte channels"), "{e}"); + // An expression-driven Selection would ignore the edit. + let mut s = session(); + s.execute_checked("prop.setExpression", json!({"path": "effects/#1/selection", "expression": "\"right\""})).unwrap(); + assert!(s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 1, "y": 1})).is_err()); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 1, "y": 1})).unwrap()["selected"], false); +} + +/// The pick reads the frame the render shows: Time Remap's source time, not the layer time. +#[test] +fn time_remap_picks_the_shown_source_frame() { + let mut s = session(); + assert_eq!(pick(&mut s, 0.5, "replace")["name"], "left"); + s.execute_checked("layer.enableTimeRemap", json!({})).unwrap(); + s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap(); + assert_eq!(pick(&mut s, 0.5, "replace")["name"], "late"); +} + +/// A precomp of a 3D comp: the generated CryptoObject pass names its layers. +#[test] +fn picks_on_a_precomp_of_a_3d_comp() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"name": "Red, Ball", "width": 40, "height": 20})).unwrap()["layer"].clone(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [l], "name": "Inner"})).unwrap(); + s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 20, "y": 10})).unwrap(); + assert_eq!((r["name"].as_str(), r["selection"].as_str()), (Some("Red, Ball"), Some("\"Red, Ball\"")), "{r}"); +} + +/// The matte the rendered Cryptomatte effect gives the picked ID at the buffer pixel under +/// `{x, y}`: the pick has to name the ID the effect's own input buffer samples there. +fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (String, f32) { + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale, "mode": "replace"})).unwrap(); + let fx = s.active_comp().unwrap().layers[0].effects().unwrap().groups().position(|g| g.match_id == "ec.3d.cryptomatte").unwrap() + 1; + s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap(); + let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap()); + let layer = &comp.layers[0]; + let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None }; + let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); + let (px, py) = buf.to_px([x, y]); + let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0]; + (r["name"].as_str().unwrap_or("?").to_string(), m) +} + +/// Between 3/4 and full resolution footage keeps its native pixels: at 0.8 the effect samples +/// layer pixel 2 (the comma name, 0.7) at x = 2.2, not the 0.8-grid pixel snapped back to 1. +#[test] +fn pick_at_scale_0_8_matches_the_native_buffer_the_effect_renders() { + let mut s = session(); + let (name, m) = rendered_matte_of_pick(&mut s, 2.2, 1.5, 0.8); + assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there"); + assert_eq!(name, COMMA); +} + +/// A half-size proxy renders a buffer at 0.5 layer pixels per pixel even at full resolution: +/// x = 6.5 lies in buffer pixel 3, which samples layer x = 7 (the `left` / `right` tie). +#[test] +fn pick_on_a_proxy_matches_the_proxy_sized_buffer() { + let mut s = session(); + let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0; + s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/crypto_proxy.exr"})).unwrap(); + let (name, m) = rendered_matte_of_pick(&mut s, 6.5, 1.5, 1.0); + assert!(m >= 0.5, "picked {name:?}, but the render gives it matte {m} there"); + assert_eq!(name, "left"); +} + +/// 1920-px footage with `left` up to x = 120 and `right` after it, and a 104-px proxy. +struct Edge; +impl FootageSource for Edge { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width, f.height))) + } + fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + let (w, h) = (1920usize, 40usize); + let mut aux = AuxChannels::new(w as u32, h as u32, 1.0); + let ids = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < 120 { "left" } else { "right" }))).collect(); + aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])]; + aux.manifests = vec![("CryptoObject".into(), ["left", "right"].iter().map(|n| (n.to_string(), crypto_hash(n))).collect())]; + Some(Arc::new(aux)) + } +} + +/// Proxy buffer pixel 6's centre maps to layer x = 6.5 / (104 / 1920): 120.0 by division, but +/// 119.99999999999999 by the reciprocal the effect multiplies with. The pick must agree. +#[test] +fn pick_maps_pixel_centres_bit_exactly_as_the_effect() { + let mut s = Session { footage: Arc::new(Edge), ..Default::default() }; + let at = |path: &str, width| Footage { + path: path.into(), + kind: FootageKind::Video, + width, + height: 40, + has_video: true, + frame_rate: FrameRate::from_f64(24.0), + duration: Tick::from_seconds_f64(2.0), + ..Default::default() + }; + s.importer = Some(Arc::new(EdgeProbe(at("/renders/edge.exr", 1920), at("/renders/edge_proxy.exr", 104)))); + let item = s.execute("file.import", json!({"paths": ["/renders/edge.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 1920, "height": 40, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s.execute("file.setProxy", json!({"item": item, "path": "/renders/edge_proxy.exr"})).unwrap(); + let (name, m) = rendered_matte_of_pick(&mut s, 118.0, 5.0, 1.0); + assert_eq!((name.as_str(), m), ("left", 1.0)); +} + +struct EdgeProbe(Footage, Footage); +impl crate::Importer for EdgeProbe { + fn probe(&self, path: &str) -> Result { + Ok(if path.contains("proxy") { self.1.clone() } else { self.0.clone() }) + } +} + +/// Footage whose decoded frames are `num / den` of the probed size (a file re-rendered at another +/// resolution, a relinked file): the render stretches them over the layer. +struct OffNominal(u32, u32); +impl FootageSource for OffNominal { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + Some(Arc::new(Image::new(f.width * self.0 / self.1, f.height * self.0 / self.1))) + } + fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option> { + Some(Arc::new(aux_at(t))) + } +} + +/// Points along rows `ys` where the picked ID isn't lit in the rendered matte under the point. +fn mismatches(s: &mut Session, scales: &[f64], xs: impl Iterator + Clone, ys: &[f64]) -> Vec { + let mut bad = vec![]; + for &scale in scales { + for (x, &y) in ys.iter().flat_map(|y| xs.clone().map(move |x| (x, y))) { + if s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": x, "y": y, "scale": scale})).unwrap().is_null() { + continue; + } + let (name, m) = rendered_matte_of_pick(s, x, y, scale); + if m < 0.5 { + bad.push(format!("scale {scale} ({x:.2}, {y}): picked {name:?}, matte {m}")); + } + } + } + bad +} + +const PREVIEW_SCALES: [f64; 7] = [1.0, 0.8, 0.75, 0.74, 0.5, 0.33, 0.25]; + +/// The pick follows the decoded frame's real size, not the footage's nominal one. +#[test] +fn pick_matches_the_render_when_frames_decode_off_nominal_size() { + for (num, den) in [(2, 1), (1, 2), (3, 4)] { + let mut s = Session { footage: Arc::new(OffNominal(num, den)), importer: Some(Arc::new(Probe)), ..Default::default() }; + let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone(); + s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": item})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..80).map(|i| i as f64 * 0.1 + 0.05), &[0.3, 1.7, 2.6]); + assert!(bad.is_empty(), "decode {num}/{den}: {} mismatches:\n{}", bad.len(), bad.join("\n")); + } +} + +/// A precomp of a 3D comp, with and without Preserve Resolution (drawn at full size below full +/// resolution), behind a disabled effect: the pick names the ID the rendered matte shows. +#[test] +fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() { + for preserve in [false, true] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "A", "width": 40, "height": 20})).unwrap()["layer"].clone(); + let b = s.execute("layer.newSolid", json!({"name": "B", "width": 7, "height": 20})).unwrap()["layer"].clone(); + s.execute_checked("prop.set", json!({"layer": b, "path": "transform/position", "value": [3.5, 10, -10]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap(); + let inner = *s.project.items.iter().find(|(_, i)| i.name == "Inner").unwrap().0; + s.execute_checked("comp.settings", json!({"comp": inner.0, "preserveResolution": preserve})).unwrap(); + s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok(); + s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap(); + s.execute_checked("effect.toggle", json!({"effect": 1, "value": false})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + let bad = mismatches(&mut s, &PREVIEW_SCALES, (0..93).map(|i| i as f64 * 0.15 + 0.05), &[10.0]); + assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n")); + } +} diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 18fcc6f84..1ea57f4cf 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -772,6 +772,27 @@ fn preset_round_trips_effect_properties_by_match_name() { } } +/// #2064: `prop.addKey` accepts the `effects/[#n]/` path that +/// `essential.list` reports. +#[test] +fn add_key_resolves_effect_match_name_paths() { + let (mut s, a, _) = comp_with_two_solids(); + apply(&mut s, a, "Slider Control"); + apply(&mut s, a, "Slider Control"); + let slider = |s: &Session, n: usize| match layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("slider").unwrap().value { + KV::Scalar(v) => v, + ref other => panic!("not a scalar: {other:?}"), + }; + for (path, n, value) in [("effects/ec.control.slider/slider", 0, 30.0), ("effects/ec.control.slider#2/slider", 1, 70.0)] { + let reply = s.execute("prop.addKey", json!({"layer": a, "path": path, "time": 0, "value": value})).unwrap(); + assert_eq!(reply, json!(1), "{path}"); + let got = s.execute("prop.get", json!({"layer": a, "path": path, "time": 0})).unwrap(); + assert_eq!(got["value"], json!(value), "{path}"); + assert_eq!(got["keys"].as_array().unwrap().len(), 1, "{path}"); + assert_eq!(slider(&s, n), 0.0, "the static value stays put; the key carries {value}"); + } +} + /// #1746: Edit ▸ Copy of an effect property writes the same `effects/ec.blur.gaussian/blurriness` path, /// and Paste (the value, or Copy Expression Only) sets that property of the other layer's effect. #[test] diff --git a/crates/engine/src/tests_key_time.rs b/crates/engine/src/tests_key_time.rs index 4d4ab367b..d42d6d4a0 100644 --- a/crates/engine/src/tests_key_time.rs +++ b/crates/engine/src/tests_key_time.rs @@ -57,7 +57,10 @@ fn default_layer_times_are_preserved_and_outside_keys_warn() { assert!(selected.get("warning").is_some()); let moved = s.execute("keys.set", json!({"layer": layer, "path": "transform/opacity", "time": 15.3, "newTime": 9.5, "timeBase": "layer"})).unwrap(); assert_seconds(&moved["compTime"], 20.0); - assert!(moved.get("warning").is_some(), "out point is exclusive"); + assert!(moved.get("warning").is_none(), "a key on the out point is accepted"); + let past = s.execute("prop.addKey", json!({"layer": layer, "path": "transform/rotation", "time": 20.1, "timeBase": "comp", "value": 45})).unwrap(); + assert_seconds(&past["compTime"], 20.1); + assert!(past.get("warning").is_some(), "a key past the out point warns"); } #[test] diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 29168e17a..33dac7795 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -613,6 +613,51 @@ fn time_set_reads_frames_style_digits_as_a_frame_count() { assert_eq!(r["display"], "00100"); } +/// Project Settings ▸ Time Display Style ▸ Frames ▸ Frame Count: Start at 0 / Start at 1 +/// (`app.project.framesCountType`), undoable and saved with the project (#1807). +#[test] +fn project_settings_frame_count_starts_at_one() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Count", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012"); + + s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap(); + assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00013"); + for bad in [json!(2), json!(-1), json!("one")] { + assert!(s.execute("file.projectSettings", json!({"frameStart": bad})).is_err(), "{bad}"); + } + assert_eq!(s.project.settings.frame_start, 1); + + let dir = std::env::temp_dir().join(format!("ec-frame-start-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("count.ecproj").to_string_lossy().to_string(); + s.execute("file.save", json!({"path": path})).unwrap(); + let mut reopened = Session::default(); + reopened.execute("file.open", json!({"path": path})).unwrap(); + let _ = std::fs::remove_dir_all(dir); + assert_eq!(reopened.project.settings.frame_start, 1); + + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.execute("time.set", json!({"frame": 12})).unwrap()["display"], "00012"); +} + +/// A Duration is a count of frames: Start at 1 and the comp's Start Timecode number positions, +/// not lengths (#1807). +#[test] +fn durations_ignore_the_frame_count_and_the_start_timecode() { + use crate::commands::time::display_duration; + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Len", "width": 64, "height": 64, "frameRate": 30, "duration": 10, "startTimecode": "0:00:01:00"})).unwrap(); + s.execute("file.projectSettings", json!({"frameStart": 1})).unwrap(); + let comp = s.active_comp().unwrap().clone(); + let len = comp.frame_rate.tick_of(30); + for (style, want) in [("frames", "00030"), ("feet35", "0001+14"), ("timecode", "0:00:01:00")] { + s.execute("file.projectSettings", json!({"timeDisplay": style})).unwrap(); + assert_eq!(display_duration(&s, &comp, len), want, "{style}"); + } +} + #[test] fn separate_dimensions_split_and_rejoin() { let (mut s, l) = setup(); diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 7229aa5ad..7f34d4b75 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -1246,6 +1246,24 @@ impl<'g> Enc<'g> { ); } + /// A `w` × `h` image showing `src` with its pixel (`x`, `y`) at the top-left, transparent + /// where `src` doesn't reach (Image::crop). + pub fn crop(&mut self, src: &GpuImage, x: i64, y: i64, w: u32, h: u32) -> GpuImage { + let dst = self.image(w, h); + let span = |o: i64, n: u32, len: u32| { + let (a, b) = (o.max(0), (o + n as i64).min(len as i64)); + (b > a).then(|| (a as u32, (a - o) as u32, (b - a) as u32)) + }; + let (Some((sx, dx, cw)), Some((sy, dy, ch))) = (span(x, w, src.width), span(y, h, src.height)) else { return dst }; + let enc = self.encoder(); + enc.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d { x: sx, y: sy, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x: dx, y: dy, z: 0 }, aspect: wgpu::TextureAspect::All }, + wgpu::Extent3d { width: cw, height: ch, depth_or_array_layers: 1 }, + ); + dst + } + /// Submit recorded work. pub fn submit(&mut self) { if let Some((enc, id)) = self.enc.take() { diff --git a/crates/gpu/src/deferred.rs b/crates/gpu/src/deferred.rs index 3ac2c67e6..e96b54a1d 100644 --- a/crates/gpu/src/deferred.rs +++ b/crates/gpu/src/deferred.rs @@ -252,6 +252,7 @@ pub(crate) fn chain_key(chain: &[FxStep], buf: &Buf, levels: Option) -> u12 k.u64(e.effect_index as u64); k.debug(&e.working_space); k.u64(e.working_linear as u64); + k.u64(e.float_depth as u64); k.debug(&e.shutter); } k.finish() diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 42d2bab71..33deb36a8 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -388,7 +388,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { // ---------------------------------------------------------------- per-pixel fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { - let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse(ctx.params.s(id))); + let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse_natural(ctx.params.s(id))); if parsed.iter().all(Option::is_none) { return Some(b); } diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs index b9ae5282c..18bd8409f 100644 --- a/crates/gpu/src/fx_color.rs +++ b/crates/gpu/src/fx_color.rs @@ -90,11 +90,10 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.color.blackwhite" => { let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0); let tc = pr.color("tintColor"); - let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); p.u[0] = [3, pr.b("tint") as u32, 0, 0]; p.f[0] = [w[0], w[1], w[2], w[3]]; p.f[1] = [w[4], w[5], 0.0, 0.0]; - p.f[2] = [th, ts, 0.0, 0.0]; + p.f[2] = [tc[0], tc[1], tc[2], 0.0]; } "ec.color.tritone" => { p.u[0][0] = 4; diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 3744ec4fa..0216d3907 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -353,10 +353,10 @@ fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0]; p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); } - let blend = ctx.params.f("blend").max(1.0); + let k = effectcraft_effects::four_color_softening(ctx.params.f("blend"), b.scale); let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; - p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + p.f[8] = [k as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; run(e, "gen_fourcolor", &p, b, None, None) } diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index f5b7708d9..d53367e76 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -637,13 +637,7 @@ fn motion_tile(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { let mirror = ctx.params.b("mirrorEdges"); let phase = ctx.params.f("phase") / 360.0; let hshift = ctx.params.b("horizontalPhaseShift"); - let (nw, nh, noff) = if ctx.adjustment { - (b.img.width, b.img.height, b.offset) - } else { - let ow = (lw * ctx.params.f("outputWidth") / 100.0).max(1.0 / s); - let oh = (lh * ctx.params.f("outputHeight") / 100.0).max(1.0 / s); - ((ow * s).round().max(1.0) as u32, (oh * s).round().max(1.0) as u32, [(ow - lw) * 0.5 * s, (oh - lh) * 0.5 * s]) - }; + let (nw, nh, noff) = effectcraft_effects::motion_tile_frame(ctx, b.offset, (b.img.width, b.img.height), s); if !e.g.fits(nw, nh) { return None; } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 1cff8be07..1064a216d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -461,3 +461,5 @@ mod tests_fx_warp; mod tests_init; #[cfg(test)] mod tests_particles; +#[cfg(test)] +mod tests_wgsl_grammar; diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 06becba49..4eea78c8c 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -179,7 +179,41 @@ fn equirect_uv(d: vec3, rot: f32) -> vec2 { return vec2(u - floor(u), v); } -fn shadow_factor(m: u32, p: vec3, n_l: f32) -> f32 { +fn shadow_texel_at(offset: u32, sz: i32, x: i32, y: i32) -> f32 { + if (x < 0 || y < 0 || x >= sz || y >= sz) { + return 1e30; // outside the map: the light was never blocked there (finite stand-in for the CPU path's infinity sentinel) + } + return shadow_texels[offset + u32(y * sz + x)]; +} + +fn tap_hash(x: i32, y: i32) -> u32 { + var h: u32 = (u32(x) * 0x9E3779B9u) ^ (u32(y) * 0x85EBCA6Bu); + h = h ^ (h >> 16u); + h = h * 0x2545F491u; + h = h ^ (h >> 15u); + return h; +} + +const PCF_MAX_TAPS: i32 = 10; +const PLANE_SLOPE_MAX: f32 = 64.0; + +// shade::shadow_factor's `open`: 1 where texel (x, y) does not occlude the receiver +// plane (depth slope -t in texel footprints per texel) at the texel's centre; the +// perspective compare runs on inverse depth (iz + g·iq), which is linear across the map. +fn shadow_open(offset: u32, sz: i32, x: i32, y: i32, ortho: bool, z: f32, texel: f32, bias: f32, t: vec2, u: f32, v: f32, iz: f32, iq: f32) -> f32 { + let s = shadow_texel_at(offset, sz, x, y) + bias; + let g = t.x * (f32(x) + 0.5 - u) + t.y * (f32(y) + 0.5 - v); + var lit: bool; + if (ortho) { + lit = z - g * texel <= s; + } else { + let inv = iz + g * iq; + lit = inv <= 0.0 || inv * s >= 1.0; + } + return select(0.0, 1.0, lit); +} + +fn shadow_factor(m: u32, p: vec3, ng: vec3, n_l: f32) -> f32 { let r0 = shadows[m]; let r1 = shadows[m + 1u]; let r2 = shadows[m + 2u]; @@ -205,28 +239,66 @@ fn shadow_factor(m: u32, p: vec3, n_l: f32) -> f32 { } let z = q.z; let texel = select(abs(z) / focal, 1.0 / focal, ortho); - let bias = extra.x * texel * (1.0 + extra.y) * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0))); - let step = extra.y / 2.0; + // CPU mirror (shade::shadow_factor), step for step: slope-scaled bias at texel + // scale (independent of the PCF radius extra.y; a diffusion-scaled bias lifts + // shadows off their casters), each texel compared against the receiver plane's + // depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid + // jittered by an integer hash (bit-identical across backends) of the sub-texel + // cell and tap index, and bilinear compares of the four nearest texel centres. + // extra.y is the wanted penumbra in output pixels; dividing by the receiver's + // texel footprint keeps it in output pixels at any map resolution, and the reach + // is at least two texels so hard edges ramp over texel steps instead of showing them. + let bias = extra.x * texel * (1.0 + 3.0 * (1.0 - clamp(n_l, 0.0, 1.0))); let sz = i32(size); + let nv = vec3(dot(r0.xyz, ng), dot(r1.xyz, ng), dot(r2.xyz, ng)); + var den0 = nv.z; + if (!ortho) { + den0 = nv.x * ((u - c) / focal) + nv.y * ((v - c) / focal) + nv.z; + } + var t = vec2(0.0, 0.0); + if (den0 < -1e-6) { + t = vec2(clamp(nv.x / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), clamp(nv.y / den0, -PLANE_SLOPE_MAX, PLANE_SLOPE_MAX)); + } + let iz = 1.0 / z; + let iq = 1.0 / (focal * z); + var reach = clamp(extra.y / texel, 2.0, 256.0); + if (!(reach < 1e30)) { + reach = 1.0; + } + let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS); + let step = reach / f32(n - 1); + let half = f32(n - 1) * 0.5; + let cx = i32(floor(u * 8.0)); + let cy = i32(floor(v * 8.0)); var lit = 0.0; - for (var j = -2; j <= 2; j = j + 1) { - for (var i = -2; i <= 2; i = i + 1) { - let x = i32(floor(u + f32(i) * step)); - let y = i32(floor(v + f32(j) * step)); - if (x < 0 || y < 0 || x >= sz || y >= sz) { - lit = lit + 1.0; - continue; - } - let d = shadow_texels[offset + u32(y * sz + x)]; - if (z - bias <= d) { - lit = lit + 1.0; - } + for (var j = 0; j < n; j = j + 1) { + for (var i = 0; i < n; i = i + 1) { + let h = tap_hash(cx + i * 7919, cy + j * 7919); + let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5; + let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5; + let su = u + ((f32(i) - half) + jx) * step; + let sv = v + ((f32(j) - half) + jy) * step; + // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the + // four is compared against the receiver plane at that centre. + let tu = su - 0.5; + let tv = sv - 0.5; + let fx = tu - floor(tu); + let fy = tv - floor(tv); + let x0 = i32(floor(tu)); + let y0 = i32(floor(tv)); + let h00 = shadow_open(offset, sz, x0, y0, ortho, z, texel, bias, t, u, v, iz, iq); + let h10 = shadow_open(offset, sz, x0 + 1, y0, ortho, z, texel, bias, t, u, v, iz, iq); + let h01 = shadow_open(offset, sz, x0, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq); + let h11 = shadow_open(offset, sz, x0 + 1, y0 + 1, ortho, z, texel, bias, t, u, v, iz, iq); + let a = h00 + (h10 - h00) * fx; + let b = h01 + (h11 - h01) * fx; + lit = lit + a + (b - a) * fy; } } - return lit / 25.0; + return lit / f32(n * n); } -fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, p: vec3, n_l: f32) -> f32 { +fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, p: vec3, ng: vec3, n_l: f32) -> f32 { if (shadow < 0) { return 1.0; } @@ -242,7 +314,7 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, face = select(5u, 4u, d.z > 0.0); } } - let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, n_l); + let f = shadow_factor((u32(shadow) + face) * SHADOW_STRIDE, p, ng, n_l); return 1.0 - darkness * (1.0 - f); } @@ -330,7 +402,7 @@ fn shadow_only_amount(unlit: bool, normal: vec3, front: bool, pos: vec3 FOut { if (!front) { n = -n; } + // Shadow lookups follow the geometric surface, not the normal map. + let ng = n; let tex_n = i32(m3.z); if (tex_n >= 0) { let t = sample_tex(tex_n, i.uv); @@ -446,7 +520,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut { } var sh = 1.0; if (receives_shadows) { - sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, n_l); + sh = light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, i.pos, ng, n_l); } let b = brdf(rgb, metallic, rough, spec_k, diff_k, n, v, to_l); acc = acc + b * l2.xyz * k * sh; diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl index 6b8641dd9..f616a2d9d 100644 --- a/crates/gpu/src/shaders/fx_color.wgsl +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -100,11 +100,36 @@ fn fxc_sc_weights(c0: vec3) -> array { return w; } +// Black & White's Tint (effects::color_fx::bw_tint): the tint's hue and saturation at the +// gray's luminosity (Color blend, weights 0.3 / 0.59 / 0.11, channels clipped toward the gray). +// The weights are effects::BW_TINT_LUMA (checked by the test shader_constants_match_the_cpu). +fn fxc_bw_tint(tint: vec3, gray: f32) -> vec3 { + if (!(gray > 0.0)) { + return vec3(0.0); + } + if (gray >= 1.0) { + return vec3(gray); + } + let w = vec3(0.3, 0.59, 0.11); + let c = tint + vec3(gray - dot(tint, w)); + let l = dot(c, w); + let n = min(min(c.x, c.y), c.z); + let x = max(max(c.x, c.y), c.z); + var o = c; + if (n < 0.0 && l - n > 1e-9) { + o = vec3(l) + (o - vec3(l)) * l / (l - n); + } + if (x > 1.0 && x - l > 1e-9) { + o = vec3(l) + (o - vec3(l)) * (1.0 - l) / (x - l); + } + return o; +} + // u[0].x = op: // 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity // 2 Vibrance f[0] = (vibrance, saturation) // 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas); -// u[0].y = tint; f[2] = (tint hue, tint saturation) +// u[0].y = tint; f[2] = tint colour // 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend // 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend) // 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome @@ -159,7 +184,7 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { let gray = max(mn + (mx - mn) * wt, 0.0); var o = vec3(gray); if (P.u[0].y != 0u) { - o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0)); + o = fxc_bw_tint(P.f[2].xyz, gray); } px = vec4(o * a, a); } @@ -167,7 +192,9 @@ fn fxc_point(@builtin(global_invocation_id) gid: vec3) { case 4u: { if (a > 0.0) { let c = px.xyz / a; - let l = clamp(luminance(c), 0.0, 1.0); + // Rec. 601 weights, as effects::color_fx::tritone: effects::LUMA_601 (checked by + // the test shader_constants_match_the_cpu). + let l = clamp(dot(c, vec3(0.299, 0.587, 0.114)), 0.0, 1.0); var t: vec3; if (l < 0.5) { t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0); diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl index f46b2f0ba..bca00ce1b 100644 --- a/crates/gpu/src/shaders/fx_generate.wgsl +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -500,7 +500,7 @@ fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3) { let dx = f32(p.x) + 0.5 - q.x; let dy = f32(p.y) + 0.5 - q.y; let d2 = dx * dx + dy * dy; - let w = 1.0 / pow(d2 / bl + 1e-6, 1.5); + let w = 1.0 / (d2 + bl); acc += P.f[4 + i].xyz * w; wsum += w; } diff --git a/crates/gpu/src/shaders/fx_key.wgsl b/crates/gpu/src/shaders/fx_key.wgsl index a28ecc65c..bee93ea65 100644 --- a/crates/gpu/src/shaders/fx_key.wgsl +++ b/crates/gpu/src/shaders/fx_key.wgsl @@ -62,6 +62,12 @@ fn fxk_range_components(space: u32, c: vec3) -> vec3 { } // keying::matte_levels. +// Luma Key / Extract luminance: Rec. 601, effects::LUMA_601 (checked by the test +// shader_constants_match_the_cpu). +fn fxk_key_luma(c: vec3) -> f32 { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + fn fxk_matte_levels(v: f32, in_b: f32, in_w: f32, gamma: f32, out_b: f32, out_w: f32) -> f32 { var t = clamp((v - in_b / 255.0) / max((in_w - in_b) / 255.0, 1e-4), 0.0, 1.0); t = powz(t, 1.0 / max(gamma, 0.01)); @@ -195,27 +201,17 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { o = vec4(select(1.0, 0.0, d <= f0.w)); } case 2u: { - let l = luminance(c); + let l = fxk_key_luma(c); let thr = f0.x; let tol = f0.y; var m: f32; + // As effects::keying::luma_key: Brighter / Darker cut hard at the threshold + // (Tolerance only shapes Similar / Dissimilar), as After Effects. switch u0.y { - case 0u: { - if (tol > 0.0) { - m = clamp((thr + tol - l) / tol, 0.0, 1.0); - } else { - m = select(1.0, 0.0, l > thr); - } - } + case 0u: { m = select(1.0, 0.0, l > thr); } case 2u: { m = select(1.0, 0.0, abs(l - thr) <= tol); } case 3u: { m = select(0.0, 1.0, abs(l - thr) <= tol); } - default: { - if (tol > 0.0) { - m = clamp((l - (thr - tol)) / tol, 0.0, 1.0); - } else { - m = select(1.0, 0.0, l < thr); - } - } + default: { m = select(1.0, 0.0, l < thr); } } o = vec4(m); } @@ -241,7 +237,7 @@ fn fxk_point(@builtin(global_invocation_id) gid: vec3) { case 2u: { v = c.y; } case 3u: { v = c.z; } case 4u: { v = a; } - default: { v = luminance(c); } + default: { v = fxk_key_luma(c); } } v = 255.0 * v; let bp = f0.x; diff --git a/crates/gpu/src/shaders/fx_noise.wgsl b/crates/gpu/src/shaders/fx_noise.wgsl index 1f62d4429..5e68e10c1 100644 --- a/crates/gpu/src/shaders/fx_noise.wgsl +++ b/crates/gpu/src/shaders/fx_noise.wgsl @@ -538,7 +538,7 @@ fn fxn_typed_noise(x: f32, y: f32, z: f32, seed: u32, nt: u32) -> f32 { let z0 = floor(z); let tx = x - x0; let ty = y - y0; - let fz = fade(z - z0); + let fz = z - z0; let a = fxn_slice(i32(x0), i32(y0), i32(z0), tx, ty, seed, nt); let b = fxn_slice(i32(x0), i32(y0), i32(z0) + 1, tx, ty, seed, nt); return a + (b - a) * fz; diff --git a/crates/gpu/src/shaders/fx_tone.wgsl b/crates/gpu/src/shaders/fx_tone.wgsl index 39db34588..c99d199c5 100644 --- a/crates/gpu/src/shaders/fx_tone.wgsl +++ b/crates/gpu/src/shaders/fx_tone.wgsl @@ -20,6 +20,14 @@ fn fxt_rem360(x: f32) -> f32 { } // Levels (Individual Controls) channel `i` (data: 5 × (ib, iw, g, ob, ow, identity)). +// color_fx::levels_gamma: unclipped values below 0 mirror the curve. +fn fxt_gamma(t: f32, g: f32) -> f32 { + if (t >= 0.0) { + return powz(t, 1.0 / g); + } + return -powz(-t, 1.0 / g); +} + fn fxt_lv(i: u32, v: f32, clip_b: bool, clip_w: bool) -> f32 { let o = i * 6u; if (data[o + 5u] != 0.0) { @@ -32,7 +40,7 @@ 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]) * powz(max(t, 0.0), 1.0 / max(data[o + 2u], 0.01)); + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, max(data[o + 2u], 0.01)); } // color_fx::level (data offset o: ib, iw, g, ob, ow, clip black, clip white). @@ -44,8 +52,7 @@ fn fxt_level(o: u32, v: f32) -> f32 { if (data[o + 6u] != 0.0) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / data[o + 2u]); - return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * t; + return data[o + 3u] + (data[o + 4u] - data[o + 3u]) * fxt_gamma(t, data[o + 2u]); } fn fxt_level_ident(o: u32) -> bool { @@ -177,7 +184,7 @@ fn fxt_point(@builtin(global_invocation_id) gid: vec3) { let aa = max(a, 0.0); var nc: vec3; for (var i = 0u; i < 3u; i++) { - nc[i] = fxt_lv(i + 1u, fxt_lv(0u, c[i], cb, cw), cb, cw); + nc[i] = fxt_lv(0u, fxt_lv(i + 1u, c[i], cb, cw), cb, cw); } let na = clamp(clamp(fxt_lv(4u, aa, cb, cw), 0.0, 1.0), 0.0, 1.0); o = vec4(nc * na, na); diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index defbb5bf4..5bb69693e 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -561,7 +561,7 @@ fn soft_linear_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 { let wy = bspline(y + 0.5 - y0); let a = soft_linear_slice(i32(x0), i32(y0), i32(z0), wx, wy, seed); let b = soft_linear_slice(i32(x0), i32(y0), i32(z0) + 1, wx, wy, seed); - let n = a + (b - a) * fade(z - z0); + let n = a + (b - a) * (z - z0); return 0.5 + (n - 0.5) * SOFT_LINEAR_GAIN; } @@ -605,7 +605,9 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { case 1u: { if (a > 0.0) { let c = straight(px); - let l = clamp(luminance(c), 0.0, 1.0); + // Rec. 601 weights, as After Effects: effects::LUMA_601 (checked by the test + // shader_constants_match_the_cpu). + let l = clamp(0.299 * c.x + 0.587 * c.y + 0.114 * c.z, 0.0, 1.0); let blk = P.f[0].xyz; let wht = P.f[1].xyz; let t = blk + (wht - blk) * l; @@ -623,14 +625,20 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { } else if (P.u[0].y == 2u) { // Brightness & Contrast tone curves (effects crate bc_modern, AE-fitted #681). var o = c; - let g = max(1.0 + 0.53 * ct, 0.05); + // Negative settings invert the positive curves (reciprocal exponents). + var g = max(1.0 + 0.53 * abs(ct), 0.05); + if (ct < 0.0) { + g = 1.0 / g; + } + var e = exp2(1.24 * abs(br)); + if (br < 0.0) { + e = 1.0 / e; + } for (var i = 0; i < 3; i++) { if (c[i] < 1.0) { var v = max(c[i], 0.0); - if (br > 0.0) { - v = 1.0 - powz(1.0 - v, exp2(1.24 * br)); - } else if (br < 0.0) { - v = powz(v, exp2(-1.32 * br)); + if (br != 0.0) { + v = 1.0 - powz(1.0 - v, e); } if (ct != 0.0) { if (v < 0.5) { @@ -702,7 +710,12 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { if (P.u[0].z != 0u) { t = min(t, 1.0); } - t = powz(max(t, 0.0), 1.0 / g); + // color_fx::levels_gamma: unclipped values below 0 mirror the curve. + if (t >= 0.0) { + t = powz(t, 1.0 / g); + } else { + t = -powz(-t, 1.0 / g); + } o[i] = ob + (ow - ob) * t; } px = vec4(o * a, a); @@ -713,14 +726,16 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = straight(px); var o = vec3(0.0); for (var i = 0; i < 3; i++) { - // Per-channel gain, offset, gamma; negative values are mirrored. + // Per-channel gain, offset, gamma; negative values are mirrored. The + // linear-light conversion is a pure 2.2 power: effects::EXPOSURE_LINEAR_GAMMA + // (checked by the test shader_constants_match_the_cpu). if (P.u[0].y != 0u) { let x = c[i] * P.f[0][i] + P.f[1][i]; o[i] = sign(x) * powz(abs(x), 1.0 / P.f[2][i]); } else { - let lin = srgb_to_linear(max(c[i], 0.0)); + let lin = sign(c[i]) * powz(abs(c[i]), 2.2); let x = lin * P.f[0][i] + P.f[1][i]; - o[i] = sign(x) * linear_to_srgb(powz(abs(x), 1.0 / P.f[2][i])); + o[i] = sign(x) * powz(powz(abs(x), 1.0 / P.f[2][i]), 1.0 / 2.2); } } px = vec4(o * a, a); @@ -753,17 +768,13 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { let c = P.f[0]; let invert = P.u[0].y != 0u; let opacity = P.f[1].x; + // The fill colour at the layer's (or inverted) alpha times Opacity (color_fx::fill). var fa = px.w; if (invert) { fa = 1.0 - px.w; } - let f = c.xyz * fa; - let rgb = px.xyz + (f - px.xyz) * opacity; - var alpha = px.w; - if (invert) { - alpha = px.w + (fa - px.w) * opacity; - } - px = vec4(rgb, alpha); + fa = fa * opacity; + px = vec4(c.xyz * fa, fa); } case 8u: { // effects::color2::map_ca: transparent pixels only change through the alpha curve. diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 79ef77b57..f825b121a 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -880,6 +880,52 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { } } +/// The GPU Tint gives After Effects 2026's greys for full-frame solids at its defaults (sRGB +/// working space, not linearised): Rec. 601 weights, as the CPU (`color_fx::luma_601`). +#[test] +fn gpu_tint_defaults_match_after_effects_greys() { + let Some(g) = gpu() else { + // Visible skip (EFFECTCRAFT_REQUIRE_GPU=1 makes a missing adapter fail instead). + eprintln!("gpu_tint_defaults_match_after_effects_greys: skipped: no GPU adapter"); + return; + }; + for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let mut s = Scene::new(BitDepth::Bpc32); + s.p.settings.working_space = Some(ColorSpace::Srgb); + let mut l = s.solid(col, 97, 61); + s.effect(&mut l, "ec.color.tint", &[]); + s.push(l); + let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + r.accel = Some(g); + let o = g.render(&r, s.cid, Tick::ZERO).expect("the GPU renders the frame").get(48, 30); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + +/// WGSL has no way to read the CPU's constants, so the kernels repeat them as literals; this +/// ties each literal to its Rust source (runs without a GPU): Rec. 601 luma for Tint (`pointwise` +/// op 1), Tritone and Luma Key / Extract, Black & White's tint weights and Exposure's 2.2 power. +#[test] +fn shader_constants_match_the_cpu() { + use effectcraft_effects::{BW_TINT_LUMA, EXPOSURE_LINEAR_GAMMA, LUMA_601}; + let kernels = include_str!("shaders/kernels.wgsl"); + let color = include_str!("shaders/fx_color.wgsl"); + let key = include_str!("shaders/fx_key.wgsl"); + let [r, g, b] = LUMA_601; + let dot = format!("dot(c, vec3({r}, {g}, {b}))"); + assert!(kernels.contains(&format!("clamp({r} * c.x + {g} * c.y + {b} * c.z, 0.0, 1.0)")), "Tint luma in kernels.wgsl is not {LUMA_601:?}"); + assert!(color.contains(&format!("clamp({dot}, 0.0, 1.0)")), "Tritone luma in fx_color.wgsl is not {LUMA_601:?}"); + assert!(key.contains(&format!("return {dot};")), "fxk_key_luma in fx_key.wgsl is not {LUMA_601:?}"); + let [r, g, b] = BW_TINT_LUMA; + assert!(color.contains(&format!("let w = vec3({r}, {g}, {b});")), "fxc_bw_tint weights are not {BW_TINT_LUMA:?}"); + let gamma = EXPOSURE_LINEAR_GAMMA; + assert!( + kernels.contains(&format!("powz(abs(c[i]), {gamma})")) && kernels.contains(&format!("1.0 / {gamma})")), + "Exposure's power in kernels.wgsl is not {gamma}" + ); +} + /// A layer whose bounds change every frame reads back at a new size each time: the staging /// buffers kept for reuse stay within budget instead of growing for the whole render (#2081). #[test] diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index 231dc9763..21422aa6b 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -167,6 +167,44 @@ fn gpu_matches_cpu_shadow_catcher() { compare(&s, &g); } +#[test] +fn gpu_matches_cpu_wide_penumbrae() { + // Both lights cast with wide Shadow Diffusion. Penumbrae cover much of the frame, so a + // backend drift in the PCF (jitter hash, tap grid, receiver-plane compare) would hide + // inside the scene-wide 1.5 % silhouette budget; here every shadowed pixel off a + // silhouette must match the CPU within 2/255. + let Some(g) = gpu() else { return }; + let (mut p, cid) = scene_project(); + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[1], "lightOptions/shadowDiffusion", Value::Scalar(48.0)); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(true)); + set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0)); + let s = scene(&p, cid); + let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster")); + for l in &mut p.comp_mut(cid).unwrap().layers[..2] { + set(l, "lightOptions/castsShadows", Value::Bool(false)); + } + let unshadowed = adv::raster::render(&scene(&p, cid)); + let w = cpu.width as usize; + let luma = |c: &[f32; 4]| (c[0] + c[1] + c[2]) / 3.0; + // Off a silhouette: the pixel and its four neighbours see one continuous surface. + let smooth = |i: usize| -> bool { + let (x, y) = (i % w, i / w); + let d = cpu.depth[i]; + x > 0 + && y > 0 + && x + 1 < w + && i + w < cpu.depth.len() + && d.is_finite() + && [i - 1, i + 1, i - w, i + w].iter().all(|&j| (cpu.depth[j] - d).abs() < 0.01 * d.abs().max(1.0)) + }; + let shadowed: Vec = (0..cpu.color.len()).filter(|&i| luma(&unshadowed.color[i]) - luma(&cpu.color[i]) > 0.01 && smooth(i)).collect(); + let bad = shadowed.iter().filter(|&&i| (0..4).any(|k| (cpu.color[i][k] - gt.color[i][k]).abs() / cpu.color[i][k].abs().max(1.0) > 2.0 / 255.0)).count(); + eprintln!("adv3d wide penumbrae: {bad}/{} shadowed pixels beyond 2/255", shadowed.len()); + assert!(shadowed.len() > 2000, "the shadows cover a large area: {}", shadowed.len()); + assert!(bad as f64 <= 0.002 * shadowed.len() as f64, "{bad}/{} shadowed pixels differ", shadowed.len()); +} + #[test] fn gpu_raster_perf_smoke() { let Some(g) = gpu() else { return }; diff --git a/crates/gpu/src/tests_fx_stylize.rs b/crates/gpu/src/tests_fx_stylize.rs index 20d76efa6..df99e1ced 100644 --- a/crates/gpu/src/tests_fx_stylize.rs +++ b/crates/gpu/src/tests_fx_stylize.rs @@ -89,6 +89,8 @@ fn tiles_kaleida_repetile() { "ec.stylize.motiontile", &[("horizontalPhaseShift", on()), ("phase", n(33.0)), ("tileCenter", pt(30.0, 17.0)), ("tileHeight", n(55.0)), ("outputWidth", n(80.0))], ); + // Output under 100 % on an adjustment crops the comp below (#1727). + effect_case("ec.stylize.motiontile", &[("outputWidth", n(60.0)), ("outputHeight", n(300.0)), ("mirrorEdges", on())]); effect_case("ec.stylize.cckaleida", &[]); effect_case("ec.stylize.cckaleida", &[("mirroring", e(2)), ("rotation", n(20.0)), ("size", n(150.0))]); effect_case("ec.stylize.ccrepetile", &[("expandRight", n(10.0)), ("expandUp", n(4.0))]); @@ -225,3 +227,42 @@ fn transform_motion_blur() { check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::from_seconds_f64(0.3)), 0.0); } } + +/// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the +/// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes +/// back onto the canvas as on the CPU (#1727). +#[test] +fn adjustment_motion_tile_output_then_transform() { + for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0), (60.0, 80.0, 100.0)] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let bg = s.footage(97, 61); + s.push(bg); + let mut adj = s.solid([1.0, 1.0, 1.0], 97, 61); + adj.switches.adjustment = true; + s.effect(&mut adj, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]); + s.effect(&mut adj, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]); + s.push(adj); + let label = format!("adjustment Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +/// The same on a full-frame footage layer (#1991). +#[test] +fn layer_motion_tile_output_then_transform() { + for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0)] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.footage(97, 61); + s.effect(&mut l, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]); + s.effect(&mut l, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]); + s.push(l); + let label = format!("layer Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} diff --git a/crates/gpu/src/tests_wgsl_grammar.rs b/crates/gpu/src/tests_wgsl_grammar.rs new file mode 100644 index 000000000..18fef7a0f --- /dev/null +++ b/crates/gpu/src/tests_wgsl_grammar.rs @@ -0,0 +1,191 @@ +//! WGSL rules naga accepts but stricter front-ends (Tint, i.e. the browser's +//! WebGPU, which receives the WGSL source verbatim on wasm) reject. +//! +//! WGSL gives the bitwise operators `&`, `|` and `^` no precedence against any other +//! binary operator: `a * b ^ c` must be written `(a * b) ^ c`, and `a & b == c` as +//! `(a & b) == c`. Only a chain of the same bitwise operator may go unparenthesised. + +use std::fs; +use std::path::Path; + +#[derive(Clone, Copy, PartialEq)] +enum Tok<'a> { + Operand, + Open, + Close, + Op(&'a str), + /// A unary prefix (`!`, `~`): the operator after it cannot be binary. + Prefix, + /// Ends an expression: `,` `;` `{` `}` `:` `=` compound assignment, `->`, `@`. + Sep, +} + +const BITWISE: [&str; 3] = ["&", "|", "^"]; + +/// Drop comments and template argument lists (`vec3`, `array>`), whose +/// angle brackets are not relational operators. +fn strip(src: &str) -> String { + let mut out = String::with_capacity(src.len()); + let b = src.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i..].starts_with(b"//") { + while i < b.len() && b[i] != b'\n' { + i += 1; + } + } else if b[i..].starts_with(b"/*") { + i += 2; + while i + 1 < b.len() && !b[i..].starts_with(b"*/") { + out.push(if b[i] == b'\n' { '\n' } else { ' ' }); + i += 1; + } + i += 2; + } else { + out.push(b[i] as char); + i += 1; + } + } + loop { + let mut changed = false; + let mut next = String::with_capacity(out.len()); + let c: Vec = out.chars().collect(); + let mut i = 0; + while i < c.len() { + if c[i] == '<' && i > 0 && (c[i - 1].is_alphanumeric() || c[i - 1] == '_') { + let mut j = i + 1; + while j < c.len() && (c[j].is_alphanumeric() || c[j] == '_' || c[j] == ',' || c[j] == ' ') { + j += 1; + } + if j < c.len() && c[j] == '>' { + i = j + 1; + changed = true; + continue; + } + } + next.push(c[i]); + i += 1; + } + out = next; + if !changed { + return out; + } + } +} + +/// `(line, message)` for every bitwise operator mixed with another binary operator at +/// the same parenthesis level. +fn violations(src: &str) -> Vec<(usize, String)> { + let s = strip(src); + let b = s.as_bytes(); + let mut out = vec![]; + // Binary operators seen at each open parenthesis level. + let mut levels: Vec> = vec![vec![]]; + let mut prev = Tok::Sep; + let (mut i, mut line) = (0, 1); + const OPS: [&str; 18] = ["&&", "||", "==", "!=", "<=", ">=", "<<", ">>", "<", ">", "+", "-", "*", "/", "%", "&", "|", "^"]; + while i < b.len() { + let ch = b[i]; + if ch == b'\n' { + line += 1; + i += 1; + continue; + } + if ch.is_ascii_whitespace() { + i += 1; + continue; + } + let tok = if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'.' { + // Identifier, member access or number (`1e-6`, `0x9E3779B9u`). + let start = i; + while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'.') { + if (b[i] == b'e' || b[i] == b'E') + && b[start].is_ascii_digit() + && !s[start..i].starts_with("0x") + && i + 1 < b.len() + && (b[i + 1] == b'-' || b[i + 1] == b'+') + { + i += 1; + } + i += 1; + } + Tok::Operand + } else if ch == b'(' || ch == b'[' { + i += 1; + levels.push(vec![]); + Tok::Open + } else if ch == b')' || ch == b']' { + i += 1; + if levels.len() > 1 { + levels.pop(); + } + Tok::Close + } else if b[i..].starts_with(b"->") || b[i..].starts_with(b"++") || b[i..].starts_with(b"--") { + i += 2; + Tok::Sep + } else if matches!(ch, b',' | b';' | b'{' | b'}' | b':' | b'@') { + i += 1; + Tok::Sep + } else if let Some(op) = OPS.iter().find(|op| b[i..].starts_with(op.as_bytes())) { + i += op.len(); + if b.get(i) == Some(&b'=') && !matches!(*op, "==" | "!=" | "<=" | ">=") { + i += 1; + Tok::Sep // compound assignment + } else { + Tok::Op(op) + } + } else if ch == b'=' { + i += 1; + Tok::Sep + } else { + i += 1; + Tok::Prefix + }; + match tok { + Tok::Sep => { + if let Some(l) = levels.last_mut() { + l.clear(); + } + } + Tok::Op(op) if matches!(prev, Tok::Operand | Tok::Close) => { + let level = levels.last_mut().expect("level"); + let mixes = |a: &str, b: &str| a != b && (BITWISE.contains(&a) || BITWISE.contains(&b)); + if let Some(other) = level.iter().find(|o| mixes(o, op)) { + out.push((line, format!("mixing '{other}' and '{op}' requires parentheses"))); + } + level.push(op); + } + _ => {} + } + prev = tok; + } + out +} + +#[test] +fn checker_flags_unparenthesised_bitwise_mixes() { + assert_eq!(violations("let h = u32(x) * 3u ^ u32(y) * 5u;").len(), 2); + assert_eq!(violations("let a = b & c == d;").len(), 1); + assert!(violations("let h = (u32(x) * 3u) ^ (u32(y) * 5u);").is_empty()); + assert!(violations("let h = a ^ b ^ c; let m = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;").is_empty()); + assert!(violations("let v: vec3 = vec3(1e-6, -x, a < b); if (a < b && c > d) { h ^= h >> 16u; }").is_empty()); + assert!(violations("var p = &x; let y = -a & b;").is_empty()); +} + +#[test] +fn shaders_parenthesise_bitwise_operators() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shaders"); + let mut bad = vec![]; + let mut files = 0; + for entry in fs::read_dir(&dir).expect("shader dir") { + let path = entry.expect("entry").path(); + if path.extension().is_some_and(|e| e == "wgsl") { + files += 1; + let src = fs::read_to_string(&path).expect("shader source"); + for (line, msg) in violations(&src) { + bad.push(format!("{}:{line}: {msg}", path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default())); + } + } + } + assert!(files > 10, "found the shaders: {files}"); + assert!(bad.is_empty(), "WGSL a strict front-end rejects:\n{}", bad.join("\n")); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index b1209070a..b6e78870c 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -26,9 +26,7 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick return None; } let ctx = r.eval_ctx(comp_id, t)?; - let s = r.opts.scale; - let w = ((ctx.comp.width as f64 * s).round() as u32).max(1); - let h = ((ctx.comp.height as f64 * s).round() as u32).max(1); + let (w, h) = effectcraft_render::frame_size(ctx.comp.width, ctx.comp.height, r.opts.scale); if r.depth() > 16 || !e.g.fits(w, h) { return None; } @@ -357,7 +355,11 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv }; let mut fx = crate::effects::GpuFx::new(e, below, [0.0; 2], r.opts.scale, pipe.levels); r.run_effects_on(ctx, layer, true, &mut fx); - let (mut adjusted, _, _) = fx.finish()?; + let (mut adjusted, offset, _) = fx.finish()?; + // An effect that changed the frame (Motion Tile's Output size): back onto the canvas. + if let Some((dx, dy)) = effectcraft_effects::adjustment_crop(offset, [0.0; 2], (adjusted.width, adjusted.height), (w, h)) { + adjusted = e.crop(&adjusted, dx, dy, w, h); + } if adjusted.width != w || adjusted.height != h { return Some(()); } diff --git a/crates/host/tests/it/cryptomatte_pick.rs b/crates/host/tests/it/cryptomatte_pick.rs new file mode 100644 index 000000000..69092ce95 --- /dev/null +++ b/crates/host/tests/it/cryptomatte_pick.rs @@ -0,0 +1,127 @@ +//! The Cryptomatte eyedropper on real OpenEXR footage (#473): `effect.pickCryptomatte` reads the +//! file's Cryptomatte ranks and manifest, edits the Selection, and the rendered matte follows, +//! including undo. + +use effectcraft_engine::Session; +use effectcraft_engine::time::Tick; +use effectcraft_raster::channels3d::{crypto_float, crypto_hash}; +use serde_json::json; + +const RIGHT: &str = "Right, \"Box\""; + +/// An 8 × 4 EXR: white RGBA, and a CryptoObject layer with `Left` in columns before `split` and +/// the comma-and-quotes name from there, both named in its manifest. +fn crypto_exr(tag: &str, split: usize) -> String { + use exr::prelude::*; + let (w, h) = (8usize, 4usize); + let ids: Vec = (0..w * h).map(|i| crypto_float(crypto_hash(if i % w < split { "Left" } else { RIGHT }))).collect(); + let ch = |n: &str, v: Vec| AnyChannel::new(n, FlatSamples::F32(v)); + let one = || vec![1.0f32; w * h]; + let channels = AnyChannels::sort( + vec![ch("R", one()), ch("G", one()), ch("B", one()), ch("A", one()), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", one())].into(), + ); + let mut layer = Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels); + let manifest = json!({"Left": format!("{:08x}", crypto_hash("Left")), RIGHT: format!("{:08x}", crypto_hash(RIGHT))}).to_string(); + for (k, v) in [("name", "CryptoObject".to_string()), ("manifest", manifest)] { + layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{k}").as_str()), AttributeValue::Text(Text::from(v.as_str()))); + } + // A folder of its own: numbered siblings would import as an image sequence. + let dir = std::env::temp_dir().join(format!("ec-crypto-pick-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("crypto.exr"); + Image::from_layer(layer).write().to_file(&path).unwrap(); + path.to_string_lossy().to_string() +} + +/// A session with the EXR as a layer of a 2 s comp, under a Cryptomatte effect. +fn footage(tag: &str, split: usize) -> (Session, String) { + let path = crypto_exr(tag, split); + let mut s = effectcraft_host::session(); + let r = s.execute("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + s.execute("comp.new", json!({"name": "Crypto", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap(); + s.execute("layer.addItem", json!({"item": r["items"][0]})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + (s, path) +} + +/// The matte's alpha over the left and right halves. +fn matte(s: &Session) -> [f32; 2] { + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + [img.get(1, 1)[3], img.get(6, 2)[3]] +} + +#[test] +fn picking_on_a_cryptomatte_exr_edits_the_selection_and_the_matte() { + let (mut s, path) = footage("edit", 4); + s.execute_checked("prop.set", json!({"path": "effects/#1/display", "value": 1})).unwrap(); + assert_eq!(matte(&s), [0.0, 0.0], "nothing selected"); + let pick = |s: &mut Session, x: f64, mode: &str| s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": x, "y": 1, "mode": mode})).unwrap(); + + assert_eq!(pick(&mut s, 1.0, "replace")["selection"], "Left"); + assert_eq!(matte(&s), [1.0, 0.0]); + let r = pick(&mut s, 6.0, "add"); + assert_eq!((r["name"].as_str(), r["layer"].as_str()), (Some(RIGHT), Some("CryptoObject")), "{r}"); + assert_eq!(r["selection"], r#"Left, "Right, \"Box\"""#); + assert_eq!(matte(&s), [1.0, 1.0], "the quoted name selects the right half"); + pick(&mut s, 2.0, "remove"); + assert_eq!(matte(&s), [0.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(matte(&s), [1.0, 1.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(matte(&s), [1.0, 0.0]); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// At half resolution a preview pixel shows the aux texel at its centre (layer x 3 for preview +/// pixel 1): `scale` makes the pick read that one, so what is clicked is what lights up. +#[test] +fn a_pick_at_preview_resolution_reads_the_pixel_the_preview_shows() { + let (mut s, path) = footage("half", 3); + s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 2.5, "y": 1.5, "scale": 0.5})).unwrap(); + assert_eq!(r["name"], RIGHT, "{r}"); + let opts = effectcraft_engine::render::RenderOpts { scale: 0.5, ..Default::default() }; + assert_eq!(s.render(s.active_comp_id().unwrap(), Tick::ZERO, opts).get(1, 0)[3], 1.0); + // At full resolution the same point is Left's pixel. + let r = s.execute_checked("effect.cryptomatteAt", json!({"effect": 1, "x": 2.5, "y": 1.5})).unwrap(); + assert_eq!(r["name"], "Left"); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// A keyframed Selection picked at `time` is edited at that time, not at the current time. +#[test] +fn a_keyframed_selection_is_edited_where_it_was_read() { + let (mut s, path) = footage("key", 4); + for t in [0.0, 1.0] { + s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": format!("<{:08x}>", crypto_hash(RIGHT))})).unwrap(); + } + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 0.5, "y": 1.5, "mode": "add", "time": 1.0})).unwrap(); + assert_eq!((r["changed"].as_bool(), r["selected"].as_bool()), (Some(true), Some(true)), "{r}"); + let sel = |s: &mut Session, t: f64| { + let r = s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t})).unwrap(); + r["selected"].as_bool() + }; + assert_eq!((sel(&mut s, 0.0), sel(&mut s, 1.0)), (Some(false), Some(true)), "only the key at 1 s gains Left"); + let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); +} + +/// A precomp whose 3D layer an expression moves: the pick finds it where the render keys it +/// (the pick's aux pass runs the session's expressions). +#[test] +fn a_pick_on_a_precomp_follows_expressions() { + let mut s = effectcraft_host::session(); + let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap()["comp"].clone(); + let l = s.execute("layer.newSolid", json!({"name": "Ball", "width": 6, "height": 6})).unwrap()["layer"].clone(); + s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap(); + s.execute_checked("prop.setExpression", json!({"layer": l, "path": "transform/position", "expression": "[33, 10, 0]"})).unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 20, "frameRate": 24, "duration": 1})).unwrap(); + s.execute("layer.addItem", json!({"item": inner})).unwrap(); + s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap(); + s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap(); + let r = s.execute_checked("effect.pickCryptomatte", json!({"effect": 1, "x": 33.5, "y": 10.5})).unwrap(); + assert_eq!(r["selection"], "Ball", "{r}"); + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + assert_eq!((img.get(33, 10)[3], img.get(20, 10)[3]), (1.0, 0.0)); + assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": 1, "x": 20.5, "y": 10.5})).unwrap(), serde_json::Value::Null); +} diff --git a/crates/host/tests/it/main.rs b/crates/host/tests/it/main.rs index 575ae946a..73fc04841 100644 --- a/crates/host/tests/it/main.rs +++ b/crates/host/tests/it/main.rs @@ -1,6 +1,7 @@ //! The crate's integration tests, one binary (each module is a former `tests/.rs`). mod background_presets; +mod cryptomatte_pick; mod determinism; mod exr_layers; mod footage_reload; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ee1d80cd3..4deefed35 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -1065,6 +1065,48 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + // Regression test for #382 (#274): resampling footage audio divided by the requested mix + // rate, so a rate of 0 panicked (division by zero); the read must come back instead. + #[test] + fn decoding_audio_at_a_zero_mix_rate_does_not_divide_by_zero() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-zero-rate"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // One second of a 440 Hz tone, 44.1 kHz mono 16-bit PCM: not the 48 kHz mix rate. + let native = 44_100u32; + let pcm: Vec = + (0..native).flat_map(|n| (((n as f32 * 440.0 * std::f32::consts::TAU / native as f32).sin() * 8000.0) as i16).to_le_bytes()).collect(); + let mut wav = b"RIFF".to_vec(); + wav.extend((36 + pcm.len() as u32).to_le_bytes()); + wav.extend(b"WAVEfmt "); + wav.extend(16u32.to_le_bytes()); + wav.extend([1u16, 1].iter().flat_map(|v| v.to_le_bytes())); + wav.extend(native.to_le_bytes()); + wav.extend((native * 2).to_le_bytes()); + wav.extend([2u16, 16].iter().flat_map(|v| v.to_le_bytes())); + wav.extend(b"data"); + wav.extend((pcm.len() as u32).to_le_bytes()); + wav.extend(&pcm); + let path = dir.join("tone.wav"); + std::fs::write(&path, &wav).unwrap(); + let footage = Footage { + path: path.to_string_lossy().into(), + kind: FootageKind::Audio, + duration: Tick::from_seconds_f64(1.0), + has_audio: true, + ..Default::default() + }; + let pool = MediaPool::new(); + // The file really is read, and resampled, at a normal rate... + let tone = pool.decode_audio(&footage, Tick::from_seconds_f64(0.1), 1024, 48_000); + assert!(tone.iter().any(|v| v.abs() > 0.05), "the tone is audible at 48 kHz"); + // ...and a mix rate of 0 (which `audio_samples` itself turns away) returns a read of the + // requested length instead of dividing by it. + let degenerate = pool.try_decode_audio(&footage, Tick::from_seconds_f64(0.1), 64, 0).unwrap(); + assert_eq!(degenerate.len(), 128); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn frame_index_loops_and_clamps() { let r = FrameRate::FPS_30; diff --git a/crates/raster/src/channels3d.rs b/crates/raster/src/channels3d.rs index b84b64b79..034fb50fd 100644 --- a/crates/raster/src/channels3d.rs +++ b/crates/raster/src/channels3d.rs @@ -148,6 +148,31 @@ impl AuxChannels { self.crypto_rank_planes(layer).iter().map(|(id, c)| (id[i], c[i])).collect() } + /// The Cryptomatte layer an effect asking for `want` (`CryptoObject`, …) reads: an exact + /// match, else one ending in it (`ViewLayer.CryptoObject`), else the first layer. + pub fn resolve_crypto_layer(&self, want: &str) -> Option { + let layers = self.crypto_layers(); + layers.iter().find(|l| l.as_str() == want || l.ends_with(want)).or(layers.first()).cloned() + } + + /// The ID (its hash, `f32::to_bits`) with the most coverage at aux pixel `i` of Cryptomatte + /// layer `layer`, summed over the ranks it appears in. Ties go to the lower rank; IDs with + /// no positive coverage don't count. `None` when nothing covers the pixel. + pub fn crypto_id_at(&self, layer: &str, i: usize) -> Option { + let mut ids: Vec<(u32, f32)> = vec![]; + for (id, cov) in self.crypto_rank_planes(layer) { + let (Some(id), Some(&c)) = (id.get(i), cov.get(i)) else { continue }; + if c > 0.0 { + match ids.iter_mut().find(|(h, _)| *h == id.to_bits()) { + Some((_, sum)) => *sum += c, + None => ids.push((id.to_bits(), c)), + } + } + } + // `min_by` keeps the first of equal elements: a tie goes to the lower rank. + ids.into_iter().min_by(|a, b| b.1.total_cmp(&a.1)).map(|(h, _)| h) + } + /// Aux pixel index of a layer-space point (nearest; `None` outside). #[inline] pub fn index_at(&self, lx: f64, ly: f64) -> Option { @@ -211,6 +236,28 @@ pub fn crypto_float(hash: u32) -> f32 { mod tests { use super::*; + /// The picked ID: most coverage (summed over ranks), ties to the lower rank, nothing when + /// no rank covers the pixel; layers resolve as the Cryptomatte effect does. + #[test] + fn crypto_id_at_takes_the_top_coverage_and_layers_resolve_by_suffix() { + let mut a = AuxChannels::new(4, 1, 1.0); + let f = |n: &str| crypto_float(crypto_hash(n)); + let (x, y, z) = (f("x"), f("y"), f("z")); + for (c, v) in [("R", [x, x, x, x]), ("G", [0.3, 0.5, 0.0, 0.2]), ("B", [y, y, y, y]), ("A", [0.6, 0.5, 0.0, 0.3])] { + a.channels.push((format!("ViewLayer.CryptoObject00.{c}"), v.to_vec())); + } + for (c, v) in [("R", [z, z, z, x]), ("G", [0.1, 0.0, -1.0, 0.2])] { + a.channels.push((format!("ViewLayer.CryptoObject01.{c}"), v.to_vec())); + } + a.channels.push(("Other00.R".into(), vec![0.0; 4])); + let l = a.resolve_crypto_layer("CryptoObject").unwrap(); + assert_eq!(l, "ViewLayer.CryptoObject"); + assert_eq!(a.resolve_crypto_layer("CryptoMaterial").as_deref(), Some("Other"), "falls back to the first"); + let at = |i| a.crypto_id_at(&l, i); + assert_eq!([at(0), at(1), at(2), at(3), at(9)], [Some(crypto_hash("y")), Some(crypto_hash("x")), None, Some(crypto_hash("x")), None]); + assert_eq!(AuxChannels::new(1, 1, 1.0).resolve_crypto_layer("CryptoObject"), None); + } + #[test] fn murmur3_reference_values() { // Published MurmurHash3_x86_32 test vectors. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 73b86e6b6..ebe0a018a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -314,26 +314,7 @@ impl EffectHost for FxHost<'_, '_, '_> { } fn aux(&self) -> Option> { - match &self.layer.source { - LayerSource::Footage { item } => { - let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; - let a = self.r.footage.aux(*item, f, self.ctx.source_time(self.layer))?; - // Aux pixels per layer pixel (the layer is the footage at its nominal size). - if f.width > 0 && a.width > 0 { - let mut a = (*a).clone(); - a.scale = a.width as f64 / f.width as f64; - return Some(Arc::new(a)); - } - Some(a) - } - LayerSource::Comp { item } => { - if self.r.depth > MAX_FX_DEPTH || self.r.project.comp_contains(*item, self.ctx.comp_id) { - return None; - } - self.r.nested().comp_aux(*item, self.ctx.nested_time(self.layer)).map(Arc::new) - } - _ => None, - } + self.r.layer_aux(self.ctx, self.layer) } fn params_at(&self, layer_time: f64) -> Option { @@ -1000,8 +981,7 @@ impl<'a> Renderer<'a> { pub fn comp_frame_cpu(&self, comp_id: ItemId, t: Tick) -> Image { let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) }; let s = self.opts.scale; - let w = ((comp.width as f64 * s).round() as u32).max(1); - let h = ((comp.height as f64 * s).round() as u32).max(1); + let (w, h) = frame_size(comp.width, comp.height, s); // Region of interest on a comp with 3D layers: render the frame and crop it. A region // reaching past the comp frame (the Extended Viewer) renders Classic 3D natively — the // planes project through the offset camera — and Advanced 3D as the frame, padded. @@ -1184,6 +1164,7 @@ impl<'a> Renderer<'a> { effect_index: 0, working_space: self.pipe.space, working_linear: self.pipe.linear, + float_depth: self.pipe.levels.is_none(), shutter: self.mb_on(ctx, layer).then_some((ctx.comp.shutter_angle, ctx.comp.shutter_phase, ctx.comp.motion_blur_samples)), }; // Video effects in stack order (index, group, spec); disabled and audio effects skipped. @@ -2051,6 +2032,12 @@ impl<'a> Renderer<'a> { color::convert(&mut below.img, &c); } let mut adjusted = self.apply_effects(ctx, layer, below, true); + // An effect that changed the frame (Motion Tile's Output size): back onto the canvas. + if let Some((dx, dy)) = + effectcraft_effects::adjustment_crop(adjusted.offset, o, (adjusted.img.width, adjusted.img.height), (canvas.width, canvas.height)) + { + adjusted.img = adjusted.img.crop(dx, dy, canvas.width, canvas.height); + } if let Some(c) = self.pipe.to_blend() { color::convert(&mut adjusted.img, &c); } @@ -2111,6 +2098,34 @@ impl<'a> Renderer<'a> { Some(three_d::compose::aux_pass(self, &ctx)) } + /// The auxiliary channels effects on `layer` read ([`EffectHost::aux`]): its multi-layer + /// OpenEXR footage's at the layer's source time (Time Remap included), or a precomp's + /// generated 3D channels at its nested time. `None` for other layers. + pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + match &layer.source { + LayerSource::Footage { item } => { + let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None }; + let a = self.footage.aux(*item, f, ctx.source_time(layer))?; + // Aux pixels per layer pixel (the layer is the footage at its nominal size). The + // planes are only copied when that changes the scale. + let scale = a.width as f64 / f.width as f64; + if f.width > 0 && a.width > 0 && a.scale != scale { + let mut a = (*a).clone(); + a.scale = scale; + return Some(Arc::new(a)); + } + Some(a) + } + LayerSource::Comp { item } => { + if self.depth > MAX_FX_DEPTH || self.project.comp_contains(*item, ctx.comp_id) { + return None; + } + self.nested().comp_aux(*item, ctx.nested_time(layer)).map(Arc::new) + } + _ => None, + } + } + /// Evaluation context of comp `comp_id` at comp time `t`. pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option> { Some(self.ctx(comp_id, self.project.comp(comp_id)?, t)) @@ -2373,6 +2388,12 @@ pub fn extended_region(w: u32, h: u32, visible: [f64; 4], margin: f64) -> Option Some([x0, y0, x1 - x0, y1 - y0]) } +/// The size of a `width` x `height` comp frame rendered at `scale` (both compositors, and the +/// frame size Composition Settings shows for a Resolution). +pub fn frame_size(width: u32, height: u32, scale: f64) -> (u32, u32) { + (((width as f64 * scale).round() as u32).max(1), ((height as f64 * scale).round() as u32).max(1)) +} + pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) } @@ -2430,6 +2451,8 @@ mod tests_mask_blur; #[cfg(test)] mod tests_mask_expansion; #[cfg(test)] +mod tests_motion_tile; +#[cfg(test)] mod tests_nested; #[cfg(test)] mod tests_paint; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f290e31ca..100ceda2f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -254,6 +254,22 @@ fn effects_run_in_pipeline() { assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}"); } +/// After Effects 2026, sRGB working space (not linearised), Tint at its defaults on full-frame +/// solids: the effect sees the encoded solid colour and weights it Rec. 601. +#[test] +fn tint_in_an_srgb_working_space_matches_after_effects_greys() { + for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] { + let (mut p, cid, comp) = setup(); + p.settings.working_space = Some(effectcraft_color::ColorSpace::Srgb); + let mut l = solid(&mut p, &comp, col, 200, 100); + add_effect(&mut p, &mut l, "ec.color.tint", [200.0, 100.0], &[]); + p.comp_mut(cid).unwrap().layers.push(l); + let o = render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 50); + let want = want / 255.0; + assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}"); + } +} + /// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place. #[test] fn card_dance_comp_camera_matches_the_default_view() { diff --git a/crates/render/src/tests_auto.rs b/crates/render/src/tests_auto.rs index c34321cea..22f1a6215 100644 --- a/crates/render/src/tests_auto.rs +++ b/crates/render/src/tests_auto.rs @@ -9,31 +9,31 @@ use effectcraft_time::{FrameRate, Tick}; use crate::{Accelerator, AutoKey, AutoPick, Backend, NoFootage, RenderOpts, Renderer}; -/// An accelerator that takes `delay` per frame (or declines with `handle = false`) and keeps -/// Auto timing history. -struct TimedAccel { - delay: std::time::Duration, +/// An accelerator that returns a representative pixel (or declines) and keeps Auto history. +struct MockAccel { handle: bool, calls: AtomicUsize, pick: AutoPick, } -impl TimedAccel { - fn new(ms: u64, handle: bool) -> TimedAccel { - TimedAccel { delay: std::time::Duration::from_millis(ms), handle, calls: Default::default(), pick: Default::default() } +impl MockAccel { + /// Construct a renderer stand-in without making assumptions about scheduling or speed. + fn new(handle: bool) -> MockAccel { + MockAccel { handle, calls: Default::default(), pick: Default::default() } } + /// Number of full-frame requests dispatched to this accelerator. fn calls(&self) -> usize { self.calls.load(Ordering::SeqCst) } } -impl Accelerator for TimedAccel { +impl Accelerator for MockAccel { fn name(&self) -> String { "timed".into() } + /// Count dispatched frames and return the same centre pixel as the CPU fixture. fn comp_frame(&self, _r: &Renderer, _comp: ItemId, _t: Tick) -> Option { self.calls.fetch_add(1, Ordering::SeqCst); - std::thread::sleep(self.delay); // The centre of the comp, as the CPU draws it (a red solid). self.handle.then(|| { let mut img = crate::Image::new(200, 100); @@ -52,6 +52,7 @@ impl Accelerator for TimedAccel { } } +/// A small red solid whose centre pixel is shared by the CPU and mock GPU fixtures. fn project() -> (Project, ItemId) { let mut p = Project::default(); p.settings.gpu_acceleration = true; @@ -64,7 +65,7 @@ fn project() -> (Project, ItemId) { } /// Render `n` frames with `backend`; how many the accelerator was asked for. -fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) -> usize { +fn frames(p: &Project, cid: ItemId, a: &MockAccel, backend: Backend, n: usize) -> usize { let before = a.calls(); for _ in 0..n { let mut r = Renderer::new(p, &NoFootage, RenderOpts { backend, ..Default::default() }); @@ -76,50 +77,60 @@ fn frames(p: &Project, cid: ItemId, a: &TimedAccel, backend: Backend, n: usize) a.calls() - before } +/// Drive the production selector with measured costs supplied by the test, not the OS clock. +fn measured_frames(pick: &AutoPick, key: AutoKey, cpu_ms: f64, gpu_ms: f64, n: usize) -> usize { + (0..n) + .filter(|_| { + let gpu = pick.choose(key); + pick.record(key, gpu, if gpu { gpu_ms } else { cpu_ms }); + gpu + }) + .count() +} + +/// A slower GPU is periodically re-probed; explicit GPU and Software Only ignore that history. #[test] fn auto_backend_moves_light_comps_to_the_cpu() { - // A light comp on a slow accelerator: after the warm-up (three frames on - // each side) Auto renders on the CPU, re-measuring the accelerator now and then. let (p, cid) = project(); - // Several times the CPU's frame time (unoptimised test builds are slow). - let t0 = std::time::Instant::now(); - for _ in 0..3 { - Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO); - } - let cpu_ms = t0.elapsed().as_millis() as u64 / 3; - let slow = TimedAccel::new((cpu_ms * 5).max(20), true); + let slow = MockAccel::new(true); + let key = AutoKey::new(cid, 1.0, false); let n = 6 + 2 * AutoPick::REPROBE as usize; - let asked = frames(&p, cid, &slow, Backend::Auto, n); + let asked = measured_frames(&slow.pick, key, 1.0, 20.0, n); assert!((3..=5).contains(&asked), "asked the slow accelerator {asked} times in {n} frames"); - let s = slow.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap(); + let s = slow.pick.stats(key).unwrap(); assert!(s.gpu_ms.unwrap() > s.cpu_ms.unwrap(), "{s:?}"); - // Backend::Gpu ignores the history; Software Only never asks. + // Check renderer wiring with known history before a real measurement can change it. + assert_eq!(frames(&p, cid, &slow, Backend::Auto, 1), 0); assert_eq!(frames(&p, cid, &slow, Backend::Gpu, 3), 3); let mut sw = p.clone(); sw.settings.gpu_acceleration = false; assert_eq!(frames(&sw, cid, &slow, Backend::Auto, 3), 0); } +/// Faster, tied and slightly noisier GPU costs stay on the GPU except during warm-up/probes. #[test] fn auto_backend_keeps_fast_accelerators() { - // As fast as the CPU (ties stay on the accelerator): every frame but the CPU warm-up and - // re-probes. let (p, cid) = project(); - let fast = TimedAccel::new(0, true); - let asked = frames(&p, cid, &fast, Backend::Auto, 40); - assert!(asked >= 36, "asked {asked} of 40"); + for gpu_ms in [1.0, 5.0, 5.5] { + let fast = MockAccel::new(true); + let asked = measured_frames(&fast.pick, AutoKey::new(cid, 1.0, false), 5.0, gpu_ms, 40); + assert!(asked >= 36, "asked {asked} of 40 for GPU cost {gpu_ms}"); + assert_eq!(frames(&p, cid, &fast, Backend::Auto, 1), 1); + } } +/// A declining accelerator is retried only periodically while the CPU supplies frames. #[test] fn auto_backend_falls_back_when_the_accelerator_declines() { let (p, cid) = project(); - let declining = TimedAccel::new(0, false); + let declining = MockAccel::new(false); let n = 2 * AutoPick::REPROBE as usize; let asked = frames(&p, cid, &declining, Backend::Auto, n); // Asked first, then only on the periodic re-probes. assert!((1..=4).contains(&asked), "asked {asked} times in {n} frames"); } +/// Periodic probes detect a formerly slow GPU becoming faster, without mixing cache keys. #[test] fn auto_pick_follows_changing_costs() { let pick = AutoPick::default(); diff --git a/crates/render/src/tests_motion_tile.rs b/crates/render/src/tests_motion_tile.rs new file mode 100644 index 000000000..7d1645a02 --- /dev/null +++ b/crates/render/src/tests_motion_tile.rs @@ -0,0 +1,169 @@ +//! Motion Tile's Output Width / Height on adjustment layers and on full-frame layers, with a +//! Transform after it that scales the tiled result into view (#1727, #1991). + +use effectcraft_color::Label; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Image, render_frame}; + +const W: u32 = 200; +const H: u32 = 100; + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 2]) -> Layer { + let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]); + l +} + +/// A white frame with a red 20×20 square in its top-left corner. +fn picture(p: &mut Project, comp: &Comp) -> Vec { + vec![solid(p, comp, [1.0, 0.0, 0.0], 20, 20, [10.0, 10.0]), solid(p, comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0])] +} + +/// Motion Tile (Output `out` %) then Transform (Scale `scale` %), on `l`. +fn tile_then_scale(p: &mut Project, l: &mut Layer, out: f64, scale: f64) { + let size = [W as f64, H as f64]; + for (id, vals) in [ + ("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out)), ("outputHeight", Value::Scalar(out))]), + ("ec.distort.transform", vec![("scaleHeight", Value::Scalar(scale)), ("scaleWidth", Value::Scalar(scale))]), + ] { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v; + } + l.props.sub_mut("effects").unwrap().children.push(g.into()); + } +} + +fn comp() -> (Project, ItemId, Comp) { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +/// The picture under an adjustment layer carrying the two effects. +fn adjustment_scene(out: f64, scale: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = comp(); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], W, H, [100.0, 50.0]); + adj.switches.adjustment = true; + tile_then_scale(&mut p, &mut adj, out, scale); + let mut layers = vec![adj]; + layers.extend(picture(&mut p, &comp)); + p.comp_mut(cid).unwrap().layers = layers; + (p, cid) +} + +/// The picture as a full-frame precomp layer carrying the two effects. +fn layer_scene(out: f64, scale: f64) -> (Project, ItemId) { + let (mut p, cid, comp) = comp(); + let inner = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let iid = p.add_item("Picture", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + let layers = picture(&mut p, &inner); + p.comp_mut(iid).unwrap().layers = layers; + let mut pre = build::layer(&mut p, &comp, "Picture", LayerSource::Comp { item: iid }, (W, H), None); + tile_then_scale(&mut p, &mut pre, out, scale); + p.comp_mut(cid).unwrap().layers.push(pre); + (p, cid) +} + +fn is(img: &Image, x: i64, y: i64, want: [f32; 4], what: &str) { + let got = img.get(x, y); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02), "{what}: ({x}, {y}) is {got:?}, want {want:?}"); +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const WHITE: [f32; 4] = [1.0; 4]; +const CLEAR: [f32; 4] = [0.0; 4]; + +/// Output 300 % then Scale 50 % about the frame centre: comp pixel (x, y) shows the tiled frame +/// at (100 + 2(x − 100), 50 + 2(y − 50)), so the red square repeats every 100 × 50 pixels and +/// the frame is covered edge to edge, the tiles from beyond the frame included. +fn assert_tiled(img: &Image, what: &str) { + for (x, y) in [(55, 30), (155, 30), (55, 80), (155, 80)] { + is(img, x, y, RED, what); + } + for (x, y) in [(5, 5), (195, 5), (5, 95), (195, 95), (100, 50), (100, 5)] { + is(img, x, y, WHITE, what); + } +} + +#[test] +fn adjustment_motion_tile_output_spreads_tiles_past_the_frame() { + let (p, cid) = adjustment_scene(300.0, 50.0); + assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "adjustment, Output 300 %"); +} + +#[test] +fn layer_motion_tile_output_spreads_tiles_past_the_frame() { + let (p, cid) = layer_scene(300.0, 50.0); + assert_tiled(&render_frame(&p, cid, Tick::ZERO, 1.0), "full-frame layer, Output 300 %"); +} + +/// Output under 100 % crops: only the centre 50 % of the frame is left, the rest is transparent +/// (After Effects shows the comp background there). +#[test] +fn adjustment_motion_tile_output_under_100_crops_the_frame() { + let (p, cid) = adjustment_scene(50.0, 100.0); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + is(&img, 100, 50, WHITE, "inside the kept centre"); + is(&img, 60, 30, WHITE, "inside the kept centre"); + for (x, y) in [(5, 5), (10, 10), (195, 95), (45, 50), (100, 20)] { + is(&img, x, y, CLEAR, "outside the kept centre"); + } +} + +/// The After Effects 2026 frames in #1727: a 1920×1080 red solid under an adjustment layer with +/// Motion Tile (Output 910 × 340 %, then 90 × 340 %) and Transform (Scale 12 %, Position 1361, 540). +/// The tiled frame, 9.1 × 3.4 comps scaled to 12 %, is a 2097 × 441 band centred on (1361, 540): +/// x 313–1920 (cut by the frame), y 320–760; at 90 % a 207-pixel column, x 1257–1465. +#[test] +fn adjustment_motion_tile_matches_the_after_effects_frames() { + for (out_w, x0, x1) in [(910.0, 313.0, 1920.0), (90.0, 1257.0, 1465.0)] { + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 1920, 1080, [960.0, 540.0]); + let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 1920, 1080, [960.0, 540.0]); + adj.switches.adjustment = true; + for (id, vals) in [ + ("ec.stylize.motiontile", vec![("outputWidth", Value::Scalar(out_w)), ("outputHeight", Value::Scalar(340.0))]), + ( + "ec.distort.transform", + vec![("scaleHeight", Value::Scalar(12.0)), ("scaleWidth", Value::Scalar(12.0)), ("position", Value::Vec2([1361.0, 540.0]))], + ), + ] { + let spec = effectcraft_effects::find(id).unwrap(); + let mut next = p.next_id; + let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [1920.0, 1080.0]); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k).unwrap().value = v; + } + adj.props.sub_mut("effects").unwrap().children.push(g.into()); + } + p.comp_mut(cid).unwrap().layers = vec![adj, red]; + // Quarter resolution: comp pixel (x, y) is output pixel (x / 4, y / 4). + let img = render_frame(&p, cid, Tick::ZERO, 0.25); + let at = |x: f64, y: f64| img.get((x / 4.0) as i64, (y / 4.0) as i64); + let what = format!("Output {out_w} %"); + for (x, y) in [(x0 + 8.0, 540.0), (x1 - 8.0, 540.0), (1361.0, 328.0), (1361.0, 752.0)] { + assert_eq!(at(x, y), RED, "{what}: ({x}, {y}) is red"); + } + for (x, y) in [(x0 - 8.0, 540.0), (1361.0, 312.0), (1361.0, 768.0), (100.0, 100.0)] { + assert_eq!(at(x, y), CLEAR, "{what}: ({x}, {y}) is empty"); + } + if x1 < 1920.0 { + assert_eq!(at(x1 + 8.0, 540.0), CLEAR, "{what}: right of the column"); + } + } +} diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 972a627bb..395057935 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -8,6 +8,9 @@ //! (already sorted back to front) are then shaded per fragment and blended over, depth-tested //! against the opaque surfaces. +use std::cmp::Reverse; +use std::collections::BinaryHeap; + use rayon::prelude::*; use super::scene::{Scene, ShadowMap, Vertex}; @@ -422,6 +425,81 @@ fn shadow_raster(s: &Scene, m: &ShadowMap, casters: &[usize]) -> Vec { d } +/// Hard cap on the shadow depth texels one render allocates (f32 depth texels). +/// Quality map sizes derive from the raster resolution, and both the resolution and +/// the light count come from the project, so without a scene-wide budget an +/// arbitrarily large comp with arbitrarily many casting lights (point lights cost +/// six faces) could balloon the allocation. 26 M texels (≈ 104 MB) holds one point +/// light's full 2048-texel cube (6 × 2048² ≈ 25.2 M) and stays under WebGPU's default +/// 128 MiB storage-binding limit; adapters with less fall back to the CPU raster. +pub(crate) const MAX_SHADOW_TEXELS: u64 = 26_000_000; + +/// Long side of a quality shadow map: one map texel per output pixel, to a power of +/// two, clamped to 512..4096 (raster sizes are user data and may be absurd). Draft +/// (interactive fast-preview) renders keep the flat 512 budget, unchanged. +pub(crate) fn shadow_map_size(width: u32, height: u32, draft: bool) -> u32 { + if draft { + return 512; + } + let side = width.max(height).clamp(512, 4096); + let mut size = 512u32; + while size < side { + size *= 2; + } + size +} + +/// Per-face size of a point light's cube map: the full map size, since each face spans +/// 90° and anything smaller is coarser than a spot light's map in the same comp (a +/// point light low over a floor shows exactly that as stair-steps), capped at 2048 so +/// one cube fits [`MAX_SHADOW_TEXELS`]. Draft keeps half the map size as before. +pub(crate) fn shadow_point_face_size(size: u32, draft: bool) -> u32 { + if draft { (size / 2).max(1) } else { size.min(2048) } +} + +/// Per-light map sizes (`None` = this light gets no shadow map). `kinds`: 0 = no +/// shadows (costs nothing), 1 = single map (spot/parallel), 2 = point cube (budgeted +/// as its six faces). Lights start at their full size; while the scene exceeds +/// [`MAX_SHADOW_TEXELS`] the largest map above its floor is halved (power-of-two +/// steps, the earliest light first on ties), and once every light is at its floor the +/// last lights lose their shadows entirely. Deterministic, and O(n log n) in the +/// light count: each light is halved at most a handful of times and dropped once. +pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) -> Vec> { + let base = shadow_map_size(width, height, draft); + let faces = |k: u32| -> u64 { if k == 2 { 6 } else { 1 } }; + let floor = |k: u32| -> u32 { if k == 2 { 128 } else { 512 } }; + let mut sizes: Vec = kinds + .iter() + .map(|&k| match k { + 0 => 0, + 2 => shadow_point_face_size(base, draft), + _ => base, + }) + .collect(); + let cost = |z: u32, k: u32| -> u64 { (z as u64) * (z as u64) * faces(k) }; + let mut total: u64 = sizes.iter().zip(kinds).map(|(&z, &k)| cost(z, k)).sum(); + // Largest size first, then the lowest index. + let mut heap: BinaryHeap<(u32, Reverse)> = + sizes.iter().zip(kinds).enumerate().filter(|(_, (z, k))| **z > floor(**k)).map(|(i, (&z, _))| (z, Reverse(i))).collect(); + while total > MAX_SHADOW_TEXELS { + let Some((z, Reverse(i))) = heap.pop() else { break }; + let k = kinds[i]; + total -= cost(z, k) - cost(z / 2, k); + sizes[i] = z / 2; + if z / 2 > floor(k) { + heap.push((z / 2, Reverse(i))); + } + } + for i in (0..sizes.len()).rev() { + if total <= MAX_SHADOW_TEXELS { + break; + } + total -= cost(sizes[i], kinds[i]); + sizes[i] = 0; + } + sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() +} + /// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`). pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { let casters: Vec = (0..s.indices.len() / 3) @@ -443,15 +521,37 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { } let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) * 0.5); let radius = ((hi[0] - lo[0]).powi(2) + (hi[1] - lo[1]).powi(2) + (hi[2] - lo[2]).powi(2)).sqrt() * 0.5 + 1.0; - let size = if draft { 512 } else { 1024 }; + // Map sizes come from the raster (a quality map gives ~one texel per output + // pixel); the per-light budget keeps the scene allocation bounded because + // comp size and light count are user input (point lights cost six faces). + let kinds: Vec = s + .lights + .iter() + .map(|l| { + if l.shadow != 0 { + 0 + } else if l.kind > 1 { + 2 + } else { + 1 + } + }) + .collect(); + let sizes = shadow_sizes(s.width, s.height, draft, &kinds); let mut maps: Vec = vec![]; for li in 0..s.lights.len() { + let Some(size) = sizes.get(li).copied().flatten() else { + s.lights[li].shadow = -1; + continue; + }; if s.lights[li].shadow != 0 { s.lights[li].shadow = -1; continue; } let l = s.lights[li]; let first = maps.len() as i32; + // Penumbra width in output pixels (the shader turns it into a tap reach at + // the receiver); the sampling bias is deliberately independent of it. let soft = 1.0 + (l.shadow_diffusion * 0.5).min(24.0); let pos = l.pos.map(|v| v as f64); let dir = l.dir.map(|v| v as f64); @@ -468,7 +568,7 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { maps.push(ShadowMap { view: basis(pos, dir, hint), focal, ortho: false, size, offset: 0, bias: 1.5, radius: soft }); } _ => { - let fs = size / 2; + let fs = size; for (f, d) in [ ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), ([-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index b75d5af7c..75b9c976f 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -157,7 +157,8 @@ pub struct ShadowMap { pub offset: u32, /// Depth bias in texels (scaled by the texel footprint at the receiver). pub bias: f32, - /// PCF spread (texels). + /// Penumbra width (output pixels) requested by Shadow Diffusion; the shader + /// converts it to a tap reach via the receiver's texel footprint. pub radius: f32, } diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 666f2e445..c7c1a40f1 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -206,36 +206,125 @@ pub fn shadow_coord(m: &ShadowMap, p: [f32; 3]) -> Option<(f32, f32, f32)> { } } -/// Fraction of light reaching `p` through shadow map `m` (1 = lit), PCF over a 5×5 grid -/// spread over `radius` texels. -pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], n_l: f32) -> f32 { +/// Integer tap jitter hash: pure u32 bit math so the WGSL backend computes +/// bit-identical jitter from the same inputs (a float hash would diverge between +/// backends and break CPU/GPU parity). +fn tap_hash(x: i32, y: i32) -> u32 { + let mut h = (x as u32).wrapping_mul(0x9E37_79B9) ^ (y as u32).wrapping_mul(0x85EB_CA6B); + h ^= h >> 16; + h = h.wrapping_mul(0x2545_F491); + h ^= h >> 15; + h +} + +/// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps +/// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment +/// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest). +const PCF_MAX_TAPS: i32 = 10; + +/// Steepest receiver-plane slope the PCF extrapolates, in texel footprints of depth per +/// texel (about 89°). Steeper planes are edge-on to the light, where their depth runs +/// away; they are clamped to this slope. +const PLANE_SLOPE_MAX: f32 = 64.0; + +/// Fraction of light reaching `p` through shadow map `m` (1 = lit). `ng` is the +/// receiver's geometric (not normal-mapped) normal facing the viewer. +/// +/// PCF over an N×N tap grid (5 ≤ N ≤ [`PCF_MAX_TAPS`]) spread over a reach derived +/// from `radius` (the wanted penumbra in output pixels) divided by the receiver's +/// texel footprint, each tap jittered by an integer hash of the receiver's +/// sub-texel cell and the tap index, and each tap comparing against the four +/// nearest texel centres with bilinear weights. The jitter detaches the tap grid +/// from the texel axes (axis-aligned binary taps are what stair-step a straight +/// shadow edge), and keying it below texel size keeps the dither finer than an +/// output pixel even where one texel covers several. The bilinear compare makes +/// each tap's contribution vary smoothly inside a texel instead of snapping. +/// +/// Every texel is compared against the depth the receiver's own plane has at that +/// texel's centre, not the receiver point's depth: a wide kernel on a surface tilted +/// towards the light would otherwise see its own nearer depth and shadow itself. +/// With that, the bias stays at texel scale; a diffusion-scaled bias lifts shadows +/// off their casters. +pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 { let Some((u, v, z)) = shadow_coord(m, p) else { return 1.0 }; let size = m.size as i64; - // Slope-scaled bias (world units are pixels). - // Texel footprint (world units) × PCF reach, steeper for grazing light. + // Slope-scaled bias at texel scale (world units are pixels), steeper for + // grazing light: it absorbs depth-raster error and the gap between the shading + // normal's plane and the faceted surface the map rasterised. let texel = if m.ortho { 1.0 / m.focal } else { z.abs() / m.focal }; - let bias = m.bias * texel * (1.0 + m.radius) * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0))); - let step = m.radius / 2.0; + let bias = m.bias * texel * (1.0 + 3.0 * (1.0 - n_l.clamp(0.0, 1.0))); + let at = |xi: i64, yi: i64| -> f32 { + if xi < 0 || yi < 0 || xi >= size || yi >= size { + return f32::INFINITY; // outside the map: the light was never blocked there (WGSL mirrors this with a large finite depth) + } + texels.get(m.offset as usize + (yi * size + xi) as usize).copied().unwrap_or(f32::INFINITY) + }; + // The receiver plane in light space as the slope of its depth across the map, in + // texel footprints per texel (`tx`, `ty`; depth grows along −t), clamped where the + // plane grazes the light. `den0` is the plane normal against the light ray through + // the receiver (the depth axis for an orthographic map), negative while it faces it. + let w = &m.view; + let nv = [0, 1, 2].map(|r| w[r][0] * ng[0] + w[r][1] * ng[1] + w[r][2] * ng[2]); + let c = m.size as f32 * 0.5; + let den0 = if m.ortho { nv[2] } else { nv[0] * ((u - c) / m.focal) + nv[1] * ((v - c) / m.focal) + nv[2] }; + let (tx, ty) = if den0 < -1e-6 { + ((nv[0] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX), (nv[1] / den0).clamp(-PLANE_SLOPE_MAX, PLANE_SLOPE_MAX)) + } else { + (0.0, 0.0) + }; + // 1 where texel (xi, yi) does not occlude the receiver plane at the texel's centre. + // Perspective depth is not linear across the map but its inverse is, so that + // compare runs on 1/depth (inv · (texel + bias) ≥ 1) without a division; a plane + // whose inverse depth reaches 0 has left the light's side and cannot be occluded. + let (iz, iq) = (1.0 / z, 1.0 / (m.focal * z)); + let open = |xi: i64, yi: i64| -> f32 { + let s = at(xi, yi) + bias; + let g = tx * (xi as f32 + 0.5 - u) + ty * (yi as f32 + 0.5 - v); + let lit = if m.ortho { + z - g * texel <= s + } else { + let inv = iz + g * iq; + inv <= 0.0 || inv * s >= 1.0 + }; + if lit { 1.0 } else { 0.0 } + }; + // `radius` is the wanted penumbra in output pixels; converting it through the + // receiver's texel footprint keeps the penumbra width in output pixels at any + // map resolution (and lets perspective penumbra grow with distance from the light). + // At least two texels: a hard edge then ramps across two texels, which hides the + // texel steps of the binary depth compare where one texel spans several output + // pixels (receivers grazing the light); a one-texel ramp leaves a sawtooth. + let mut reach = (m.radius / texel).clamp(2.0, 256.0); + if !reach.is_finite() { + reach = 1.0; + } + let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS); + let step = reach / (n - 1) as f32; + let half = (n - 1) as f32 * 0.5; + let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32); let mut lit = 0.0; - for j in -2..=2 { - for i in -2..=2 { - let x = (u + i as f32 * step).floor() as i64; - let y = (v + j as f32 * step).floor() as i64; - if x < 0 || y < 0 || x >= size || y >= size { - lit += 1.0; - continue; - } - let d = texels[m.offset as usize + (y * size + x) as usize]; - if z - bias <= d { - lit += 1.0; - } + for j in 0..n { + for i in 0..n { + let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919))); + let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5; + let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step); + // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the + // four is compared against the receiver plane at that centre. + let (tu, tv) = (su - 0.5, sv - 0.5); + let (fx, fy) = (tu - tu.floor(), tv - tv.floor()); + let (x0, y0) = (tu.floor() as i64, tv.floor() as i64); + let (h00, h10, h01, h11) = (open(x0, y0), open(x0 + 1, y0), open(x0, y0 + 1), open(x0 + 1, y0 + 1)); + let a = h00 + (h10 - h00) * fx; + let b = h01 + (h11 - h01) * fx; + lit += a + (b - a) * fy; } } - lit / 25.0 + lit / (n * n) as f32 } /// The visibility of light `li` at `p` (1 = unshadowed), darkened by Shadow Darkness. -pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 { +pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) -> f32 { if l.shadow < 0 { return 1.0; } @@ -258,7 +347,7 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 { s.shadows.get(first) }; let Some(m) = m else { return 1.0 }; - let f = shadow_factor(m, &s.shadow_texels, p, n_l); + let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l); 1.0 - l.shadow_darkness * (1.0 - f) } @@ -367,6 +456,8 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { if !f.front { n = scale(n, -1.0); } + // Shadow lookups follow the geometric surface, not the normal map. + let ng = n; if let Some(t) = tex(m.tex_normal) { let tn = [(t[0] * 2.0 - 1.0) * m.normal_scale, (t[1] * 2.0 - 1.0) * m.normal_scale, t[2] * 2.0 - 1.0]; let tg = [f.tangent[0], f.tangent[1], f.tangent[2]]; @@ -392,7 +483,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { if n_l <= 0.0 { continue; } - let sh = if m.receives_shadows { light_shadow(s, l, f.pos, n_l) } else { 1.0 }; + let sh = if m.receives_shadows { light_shadow(s, l, f.pos, ng, n_l) } else { 1.0 }; let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l); for c in 0..3 { out[c] += b[c] * l.color[c] * k * sh; @@ -433,7 +524,7 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] { if n_l <= 0.0 { continue; } - amount = amount.max(1.0 - light_shadow(s, l, f.pos, n_l)); + amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l)); } [0.0, 0.0, 0.0, alpha * amount] } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 694a5c2be..3f5ee4813 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -681,3 +681,369 @@ fn stroke_regions_are_rings() { assert_eq!(outlines.len(), 1); assert_eq!(outlines[0].holes.len(), 1); } + +// ---------------------------------------------------------------- shadow quality + +use super::raster::{MAX_SHADOW_TEXELS, shadow_map_size, shadow_point_face_size, shadow_sizes}; +use super::scene::ShadowMap; + +/// Geometric normal of a receiver squarely facing a `flat_map` light (which looks along +z). +const FACING: [f32; 3] = [0.0, 0.0, -1.0]; + +/// Identity-view orthographic map: map coords are world x, y × `focal` offset by +/// `size/2` (`focal` texels per world unit). +fn flat_map(size: u32, focal: f32, radius: f32) -> ShadowMap { + ShadowMap { view: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], focal, ortho: true, size, offset: 0, bias: 1.5, radius } +} + +/// Wall: texel columns ≥ a quarter of the map at depth 10, the rest unoccluded (1000). +/// Its edge sits at world x = −size / (4·focal). +fn wall_map(size: u32, focal: f32, radius: f32) -> (ShadowMap, Vec) { + let texels: Vec = (0..size * size).map(|i| if i % size >= size / 4 { 10.0 } else { 1000.0 }).collect(); + (flat_map(size, focal, radius), texels) +} + +/// Luma of a render of `cid` with its top layer (a light) set to cast shadows or not, +/// at the given Shadow Diffusion. +fn render_light_luma(p: &mut Project, cid: ItemId, casting: bool, diffusion: f64) -> Vec { + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(casting)); + set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(diffusion)); + render(p, cid).data.iter().map(|q| (q[0] + q[1] + q[2]) / 3.0).collect() +} + +#[test] +fn shadow_map_size_tracks_raster_resolution() { + assert_eq!(shadow_map_size(640, 360, false), 1024); + assert_eq!(shadow_map_size(1920, 1080, false), 2048); + assert_eq!(shadow_map_size(3840, 2160, false), 4096); + // Clamped to the pow2 range: small or absurd rasters stay inside 512..4096. + assert_eq!(shadow_map_size(64, 64, false), 512); + assert_eq!(shadow_map_size(0, 0, false), 512); + assert_eq!(shadow_map_size(u32::MAX, u32::MAX, false), 4096); + assert_eq!(shadow_map_size(7, 100000, false), 4096); + // The fast-preview (draft) budget stays at 512 regardless, so interactivity is + // untouched by the quality path's new sizing. + assert_eq!(shadow_map_size(3840, 2160, true), 512); + assert_eq!(shadow_map_size(0, 0, true), 512); +} + +#[test] +fn shadow_point_faces_are_capped() { + // Cube faces take the full map size up to 2048 (one 2048 cube ≈ 25 M f32 texels). + assert_eq!(shadow_point_face_size(4096, false), 2048); + assert_eq!(shadow_point_face_size(2048, false), 2048); + assert_eq!(shadow_point_face_size(1024, false), 1024); + assert_eq!(shadow_point_face_size(512, false), 512); + // Draft keeps the historical half-size face. + assert_eq!(shadow_point_face_size(512, true), 256); +} + +fn budget_texels(sizes: &[Option], kinds: &[u32]) -> u64 { + sizes.iter().zip(kinds).map(|(s, &k)| s.map(|z| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).unwrap_or(0)).sum() +} + +#[test] +fn shadow_sizes_stay_within_the_scene_budget() { + // Within budget a light gets its full size: one 4K point cube keeps 2048 faces. + assert_eq!(shadow_sizes(4096, 2160, false, &[2]), vec![Some(2048)]); + // Several cubes overflow: deterministically halved (largest first, earliest light + // on ties) until inside: three or four settle at 1024, five push two to 512. + assert_eq!(shadow_sizes(4096, 2160, false, &[2; 3]), vec![Some(1024); 3]); + assert_eq!(shadow_sizes(4096, 2160, false, &[2; 4]), vec![Some(1024); 4]); + let sizes = shadow_sizes(4096, 2160, false, &[2; 5]); + assert_eq!(sizes, vec![Some(512), Some(512), Some(1024), Some(1024), Some(1024)]); + assert!(budget_texels(&sizes, &[2; 5]) <= MAX_SHADOW_TEXELS); + // A hostile light count can never break the cap; sizes only shrink or drop to None. + let many = vec![2u32; 1000]; + let sizes = shadow_sizes(3840, 2160, false, &many); + assert_eq!(sizes.len(), 1000); + assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS); + assert!(sizes.iter().flatten().all(|&z| (128..=2048).contains(&z))); + // Deterministic: same inputs, same plan. + assert_eq!(sizes, shadow_sizes(3840, 2160, false, &many)); + // Non-point lights and a modest scene keep full quality sizes. + assert_eq!(shadow_sizes(1920, 1080, false, &[1; 2]), vec![Some(2048), Some(2048)]); + // A light that casts nothing costs nothing: it stays None and leaves its + // full-size budget to the lights that do cast. + assert_eq!(shadow_sizes(640, 360, false, &[0, 1]), vec![None, Some(1024)]); + assert!(shadow_sizes(640, 360, false, &[0; 500]).iter().all(Option::is_none)); +} + +#[test] +fn diffusion_does_not_lift_a_deep_shadow() { + // Shadow Diffusion may widen the penumbra but must not scale the depth bias: a + // point deep inside a shadow stays fully shadowed at any diffusion. + let (m, texels) = wall_map(64, 1.0, 1.0); + let deep = [0.0f32, 0.0, 13.5]; // u = 32: ±16 texels of margin around the edge at 16; z − bias 12 > 10 + let hard = shade::shadow_factor(&m, &texels, deep, FACING, 1.0); + assert!(hard < 0.01, "deep shadow at radius 1: {hard}"); + let wide = shade::shadow_factor(&flat_map(64, 1.0, 25.0), &texels, deep, FACING, 1.0); + assert!(wide < 0.01, "deep shadow at radius 25 (bias must not grow with radius): {wide}"); +} + +#[test] +fn pcf_varies_within_a_texel() { + // Nearest-texel PCF snaps the edge to texel boundaries: two receivers 1/20 texel + // apart get bit-identical factors, which is what stair-steps straight edges. + // The filtering must produce coverage that varies within a single texel. + let (m, texels) = wall_map(64, 1.0, 2.0); + let mut pairs = 0; + let mut varying = 0; + let mut u = -17.0f32; + while u < -15.3 { + let a = shade::shadow_factor(&m, &texels, [u, 0.0, 13.5], FACING, 1.0); + let b = shade::shadow_factor(&m, &texels, [u + 0.05, 0.0, 13.5], FACING, 1.0); + pairs += 1; + if (a - b).abs() > 1e-6 { + varying += 1; + } + assert!((0.0..=1.0).contains(&a) && (0.0..=1.0).contains(&b), "{a} {b}"); + u += 0.05; + } + assert!(pairs >= 30); + assert_eq!(varying, pairs, "coverage varies inside every texel along the edge"); +} + +#[test] +fn wide_diffusion_keeps_shadow_depth_and_contact() { + // Scene: camera-facing floor, sphere casting, point light behind the camera and + // off to the upper right so most of the shadow lands beside the sphere, not + // behind it. Raising Shadow Diffusion must neither wash the shadow out (a + // diffusion-scaled bias leaks light into it) nor move where it touches. + let (mut p, cid) = project(200, 150); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(1)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| { + set(l, "transform/position", Value::Vec3([180.0, 20.0, -300.0])); + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let noshadow = render_light_luma(&mut p, cid, false, 0.0); + let d0 = render_light_luma(&mut p, cid, true, 0.0); + let d40 = render_light_luma(&mut p, cid, true, 40.0); + // Per-column darkening vs the shadowless render: depth (max darkening) and the + // deepest dark row (contact edge). Diffusion may blur the edge but must keep the + // shadow dark (no bias leak) and keep it where it touches (no lift). + let dep = |img: &[f32], x: usize| -> f32 { (20..140).map(|y| noshadow[y * 200 + x] - img[y * 200 + x]).fold(0.0f32, f32::max) }; + let cols: Vec = (30..170).filter(|&x| dep(&d0, x) > 0.1).collect(); + assert!(cols.len() > 30, "shadow covers many columns: {}", cols.len()); + // Core depth: the 90th-percentile darkening over the d0 shadow's pixels. A blur + // only softens the rim and leaves the core dark; a bias leak lightens all of it. + let shadow_px: Vec = (20..140).flat_map(|y| (30..170).map(move |x| y * 200 + x)).filter(|&i| noshadow[i] - d0[i] > 0.1).collect(); + let core = |img: &[f32]| -> f32 { + let mut v: Vec = shadow_px.iter().map(|&i| noshadow[i] - img[i]).collect(); + v.sort_by(f32::total_cmp); + v[v.len() * 9 / 10] + }; + let (core0, core40) = (core(&d0), core(&d40)); + // Width of the shadow across its centre row: a diffusion-scaled bias shrinks the + // shadow (light leaks under its edges), while a correct filter keeps the extent + // and only softens the boundary (threshold crossings move at most with the + // penumbra, not with the bias). + let width = |img: &[f32]| -> f32 { + (30..130) + .map(|y| { + let dark: Vec = (0..200).map(|x| noshadow[y * 200 + x] - img[y * 200 + x] > 0.06).collect(); + match (dark.iter().position(|&d| d), dark.iter().rposition(|&d| d)) { + (Some(l), Some(r)) if r > l => (r - l) as f32, + _ => 0.0, + } + }) + .fold(0.0f32, f32::max) + }; + let (w0, w40) = (width(&d0), width(&d40)); + let wratio = w40 / w0; + assert!(w0 > 20.0, "shadow is wide enough to judge: {w0}"); + assert!(core40 >= 0.9 * core0, "diffusion 40 keeps the shadow core dark: {core40:.4} vs {core0:.4}"); + assert!(wratio >= 0.8, "diffusion 40 does not shrink the shadow: width {w40} vs {w0}"); +} + +#[test] +fn penumbra_dithers_along_a_straight_edge() { + // Receivers at the same distance from a straight edge, one texel apart along it. + // A fixed tap grid gives each the same coverage, so the penumbra repeats texel by + // texel (stair-steps and bands); the jitter, keyed below texel size, must vary it. + let (m, texels) = wall_map(64, 1.0, 6.0); + let distinct = |vs: &[f32]| -> usize { + let mut bits: Vec = vs.iter().map(|v| v.to_bits()).collect(); + bits.sort_unstable(); + bits.dedup(); + bits.len() + }; + let along: Vec = (0..32).map(|k| shade::shadow_factor(&m, &texels, [-16.0, -16.0 + k as f32, 13.5], FACING, 1.0)).collect(); + assert!(distinct(&along) >= 16, "coverage along the edge takes {} distinct values", distinct(&along)); + // And finer than a texel: where one texel spans several output pixels (a grazing + // floor), dither keyed per texel would show as texel-sized blocks of grain. + let within: Vec = (0..8).map(|k| shade::shadow_factor(&m, &texels, [-16.0, 0.0625 + k as f32 * 0.125, 13.5], FACING, 1.0)).collect(); + assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within)); +} + +#[test] +fn penumbra_width_is_independent_of_map_resolution() { + // One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and + // a 256-texel map at 4. Shadow Diffusion sets the penumbra in output pixels (world + // units here), so a finer map must not narrow it. + let width = |size: u32, focal: f32| -> f32 { + let (m, texels) = wall_map(size, focal, 8.0); + let xs: Vec = (0..400).map(|k| -30.0 + k as f32 * 0.07).collect(); + let f: Vec = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, 0.0, 13.5], FACING, 1.0)).collect(); + let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]); + first_below(0.1) - first_below(0.9) + }; + let (w64, w256) = (width(64, 1.0), width(256, 4.0)); + assert!(w64 > 4.0 && w64 < 12.0, "penumbra spans about the requested 8 units: {w64}"); + assert!((w256 / w64 - 1.0).abs() < 0.25, "same penumbra at any map resolution: {w64} vs {w256}"); +} + +#[test] +fn tilted_receivers_do_not_shadow_themselves() { + // A receiver plane z = 100 + 4x (76° from the light, n·l ≈ 0.24), each texel holding + // its depth half a texel off-centre the way a rasteriser can. Wide taps must compare + // against the plane rather than the receiver point, and the slope-scaled bias must + // absorb the half-texel error, at every diffusion. + let (size, g) = (128u32, 4.0f32); + let c = size as f32 * 0.5; + let texels: Vec = (0..size * size).map(|i| 100.0 + g * ((i % size) as f32 - c)).collect(); + let len = (1.0 + g * g).sqrt(); + let (ng, n_l) = ([g / len, 0.0, -1.0 / len], 1.0 / len); + for radius in [1.0f32, 6.0, 25.0] { + let m = flat_map(size, 1.0, radius); + for k in 0..40 { + let x = -20.0 + k as f32 * 0.97; + let f = shade::shadow_factor(&m, &texels, [x, 3.3, 100.0 + g * x], ng, n_l); + assert!(f > 0.99, "radius {radius}, x {x}: lit tilted receiver shadowed itself ({f})"); + } + } +} + +#[test] +fn lit_casters_do_not_shadow_themselves() { + // A sphere and a turned cube that cast shadows, lit by each light kind: the lit + // surface must render as bright as with shadows off at any Shadow Diffusion (a + // wide kernel on a surface tilted to the light must not see its own depth). + // 640×360: faces then span enough texels that near-grazing ones exercise the + // receiver plane's slope limit. + for (prim, rot_y) in [(PrimitiveKind::Sphere, 0.0), (PrimitiveKind::Cube, 35.0)] { + for kind in [LightKind::Point, LightKind::Spot, LightKind::Parallel] { + let (mut p, cid) = project(640, 360); + add(&mut p, cid, LayerSource::Primitive { kind: prim }, |l| { + set(l, "transform/position", Value::Vec3([320.0, 180.0, 100.0])); + if prim == PrimitiveKind::Sphere { + set(l, "geometryOptions/radius", Value::Scalar(120.0)); + } else { + for k in ["width", "height", "depth"] { + set(l, &format!("geometryOptions/{k}"), Value::Scalar(216.0)); + } + set(l, "transform/rotationX", Value::Scalar(-25.0)); + } + set(l, "transform/rotationY", Value::Scalar(rot_y)); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + set(l, "materialOptions/castsShadows", Value::Enum(1)); + }); + add(&mut p, cid, LayerSource::Light { kind }, |l| { + set(l, "transform/position", Value::Vec3([-280.0, -108.0, -600.0])); + if let Some(poi) = l.props.prop_mut("transform/pointOfInterest") { + poi.value = Value::Vec3([320.0, 180.0, 100.0]); + } + }); + let noshadow = render_light_luma(&mut p, cid, false, 0.0); + let lit: Vec = (0..noshadow.len()).filter(|&i| noshadow[i] > 0.15).collect(); + assert!(lit.len() > 10_000, "{prim:?} {kind:?}: lit area {}", lit.len()); + for diffusion in [0.0, 20.0, 48.0] { + let img = render_light_luma(&mut p, cid, true, diffusion); + let dark = lit.iter().filter(|&&i| img[i] < 0.9 * noshadow[i]).count() as f32 / lit.len() as f32; + assert!(dark <= 0.02, "{prim:?} {kind:?} diffusion {diffusion}: {:.1} % of the lit surface self-shadowed", dark * 100.0); + } + } + } +} + +#[test] +fn shadow_sizes_match_the_halve_largest_rule_and_scale_to_many_lights() { + // The specification `shadow_sizes` implements, step by step: halve the largest map + // above its floor (earliest light on ties) until the scene fits, then drop maps + // from the last light. + let spec = |w: u32, h: u32, draft: bool, kinds: &[u32]| -> Vec> { + let base = shadow_map_size(w, h, draft); + let mut sizes: Vec = kinds + .iter() + .map(|&k| { + if k == 0 { + 0 + } else if k == 2 { + shadow_point_face_size(base, draft) + } else { + base + } + }) + .collect(); + let total = |s: &[u32]| -> u64 { s.iter().zip(kinds).map(|(&z, &k)| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).sum() }; + while total(&sizes) > MAX_SHADOW_TEXELS { + let pick = (0..sizes.len()).filter(|&i| sizes[i] > if kinds[i] == 2 { 128 } else { 512 }).max_by_key(|&i| (sizes[i], std::cmp::Reverse(i))); + match pick { + Some(i) => sizes[i] /= 2, + None => match sizes.iter().rposition(|&z| z > 0) { + Some(i) => sizes[i] = 0, + None => break, + }, + } + } + sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() + }; + let mut seed = 0x2545_F491u32; + for case in 0..300 { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let n = (seed >> 8) as usize % 40; + let kinds: Vec = (0..n) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 16) % 3 + }) + .collect(); + let (w, h, draft) = ([640, 1920, 3840, 8000][case % 4], [360, 1080, 2160, 100][case % 4], case % 7 == 0); + assert_eq!(shadow_sizes(w, h, draft, &kinds), spec(w, h, draft, &kinds), "case {case}: {kinds:?}"); + } + // A hostile light count stays fast (n log n) and inside the budget. + let many = vec![2u32; 100_000]; + let sizes = shadow_sizes(3840, 2160, false, &many); + assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS); +} + +#[test] +fn wide_penumbrae_are_smooth() { + // Coverage at a fixed distance across a wide penumbra, sampled along a straight + // edge, should be one smooth value plus fine dither. A 5×5 grid stretched over a + // 20-texel reach leaves gaps the jitter turns into coarse grain (σ ≈ 0.04, ten + // 8-bit levels); the grid grows with the reach to keep it below 0.02. + let (m, texels) = wall_map(256, 1.0, 20.0); + for off in [-5.0f32, 0.0, 5.0] { + let vs: Vec = (0..200).map(|k| shade::shadow_factor(&m, &texels, [-64.0 + off, -100.0 + k as f32, 13.5], FACING, 1.0)).collect(); + let mean = vs.iter().sum::() / vs.len() as f32; + let sd = (vs.iter().map(|v| (v - mean).powi(2)).sum::() / vs.len() as f32).sqrt(); + assert!(sd < 0.02, "offset {off}: penumbra grain σ = {sd:.4}"); + } +} + +#[test] +fn hard_edges_ramp_over_two_texels() { + // At Shadow Diffusion 0 the edge still ramps across about two texels (10–90 %): + // where one texel spans several output pixels (a floor grazing a point light) a + // one-texel ramp leaves the binary compare's texel steps visible as a sawtooth. + let (m, texels) = wall_map(64, 1.0, 1.0); + for v0 in [0.0f32, 0.3, 7.6] { + let xs: Vec = (0..400).map(|k| -20.0 + k as f32 * 0.02).collect(); + let f: Vec = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, v0, 13.5], FACING, 1.0)).collect(); + let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]); + let w = first_below(0.1) - first_below(0.9); + assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels"); + } +} diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 90b03ae59..23356c064 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -85,6 +85,7 @@ fn project(s: &Session) -> J { "workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(), "workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 }, "timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames, + "displayStartFrame": p.settings.frame_start, "renderQueue": p.render_queue.len(), "revision": s.revision, }) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index c7f071183..8f31486bd 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -98,6 +98,8 @@ var ImportAsType = __enum("ImportAsType", ["COMP_CROPPED_LAYERS", "FOOTAGE", "CO ImportAsType.COMPOSITION_CROPPED_LAYERS = ImportAsType.COMP_CROPPED_LAYERS; ImportAsType.COMPOSITION = ImportAsType.COMP; var TimeDisplayType = __enum("TimeDisplayType", ["FRAMES", "TIMECODE"], 2012); +// After Effects' values (FC_START_0 = 2612), as in docsforadobe/Types-for-Adobe. +var FramesCountType = __enum("FramesCountType", ["FC_START_0", "FC_START_1", "FC_TIMECODE_CONVERSION"], 2612); var LogType = __enum("LogType", ["ERRORS_ONLY", "ERRORS_AND_SETTINGS", "ERRORS_AND_PER_FRAME_INFO"], 3212); var KeyframeLabel = __enum("KeyframeLabel", ["NONE"], 0); @@ -1894,6 +1896,14 @@ Project.prototype = { set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); }, get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; }, set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); }, + get framesCountType() { return this.__info().displayStartFrame === 1 ? FramesCountType.FC_START_1 : FramesCountType.FC_START_0; }, + set framesCountType(v) { + if (v === FramesCountType.FC_TIMECODE_CONVERSION) throw __err("framesCountType: Timecode Conversion is not supported, use FC_START_0 or FC_START_1"); + if (v !== FramesCountType.FC_START_0 && v !== FramesCountType.FC_START_1) throw __err("Bad framesCountType value " + v); + __call("file.projectSettings", { frameStart: v === FramesCountType.FC_START_1 ? 1 : 0 }); + }, + get displayStartFrame() { return this.__info().displayStartFrame; }, + set displayStartFrame(v) { __call("file.projectSettings", { frameStart: __num(v) }); }, get expressionEngine() { return "javascript-1.0"; }, set expressionEngine(v) {}, item: function (i) { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 9777a0f43..d7a9001a8 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -611,6 +611,26 @@ fn engine_entry_points() { let _ = std::fs::remove_dir_all(&dir); } +/// `app.project.framesCountType` / `displayStartFrame`: Project Settings ▸ Frame Count (#1807). +#[test] +fn frames_count_type_sets_the_project_frame_count() { + let mut s = session(); + let o = ok( + &mut s, + "var p = app.project; var a = [p.framesCountType === FramesCountType.FC_START_0, p.displayStartFrame]; p.framesCountType = FramesCountType.FC_START_1; a.concat([p.framesCountType === FramesCountType.FC_START_1, p.displayStartFrame]).join('|')", + ); + assert_eq!(o.result, json!("true|0|true|1")); + // After Effects' enum values, so scripts that store the number keep working. + let o = ok(&mut s, "[FramesCountType.FC_START_0, FramesCountType.FC_START_1, FramesCountType.FC_TIMECODE_CONVERSION].join('|')"); + assert_eq!(o.result, json!("2612|2613|2614")); + assert_eq!(s.project.settings.frame_start, 1); + ok(&mut s, "app.project.displayStartFrame = 0"); + assert_eq!(s.project.settings.frame_start, 0); + let o = run_code(&mut s, "app.project.framesCountType = FramesCountType.FC_TIMECODE_CONVERSION", "test.jsx"); + assert!(o.error.is_some_and(|e| e.message.contains("Timecode Conversion"))); + assert_eq!(s.project.settings.frame_start, 0); +} + #[test] fn console_keeps_variables() { let mut s = session(); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 24cc584e0..60f39adc6 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -392,7 +392,17 @@ enum PaintRef { Current, } -/// A CSS rule: selector (type, class, id; `*`) with its specificity and declarations. +/// The most selectors (each entry of a comma list counts) the `{rects}"#).into_bytes() +} + +#[test] +fn comma_selector_lists_share_one_declaration_block() { + // Before #2140 every selector of a comma list got its own copy of the declaration block, so + // `a,a,a…{…}` stored selectors × declarations strings; one selector past the cap is refused. + let sels = vec!["rect"; MAX_CSS_SELECTORS + 1].join(","); + let decls: String = (0..10).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("{sels}{{fill:red;{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("selectors"), "{e}"); +} + +#[test] +fn a_style_sheet_with_too_many_declarations_errors() { + let decls: String = (0..=MAX_CSS_DECLARATIONS).map(|i| format!("p{i}:v;")).collect(); + let svg = styled(&format!("rect{{{decls}}}"), &[""]); + let e = parse(&svg).unwrap_err(); + assert!(e.to_string().contains("declarations"), "{e}"); +} + +#[test] +fn selector_lists_keep_the_css_cascade() { + let green = Some(Paint::Color([0.0, 1.0, 0.0])); + let blue = Some(Paint::Color([0.0, 0.0, 1.0])); + let red = Some(Paint::Color([1.0, 0.0, 0.0])); + let css = "rect, .b, #c { fill: #00ff00 } .b, .d { fill: #0000ff; stroke: #000000; stroke-width: 3 } rect.e, #c { fill: #ff0000 }"; + let svg = styled(css, &["", r#"class="b""#, r#"id="c" class="b""#, r#"class="b" style="fill:#00ff00""#, r##"class="e" fill="#0000ff""##, r#"class="d""#]); + let doc = parse(&svg).unwrap(); + let shapes = doc.flatten(); + let fills: Vec<_> = shapes.iter().map(|s| s.2.fill.clone()).collect(); + // Type selector; class beats type; id beats class; inline style beats every rule; a later, + // more specific rule beats the presentation attribute. + assert_eq!(fills, vec![green.clone(), blue.clone(), red.clone(), green, red, blue]); + assert!(shapes[0].2.stroke.is_none()); + // The second list's block reaches both of its selectors. + for i in [1, 5] { + assert_eq!(shapes[i].2.stroke.as_ref().map(|s| s.width), Some(3.0), "shape {i}"); + } +} + +#[test] +fn a_comma_list_stores_its_declarations_once() { + let sels = vec!["rect"; 1000].join(","); + let decls: String = (0..50).map(|i| format!("p{i}:v;")).collect(); + let mut sheet = StyleSheet::default(); + parse_css(&format!("{sels}{{{decls}}}"), &mut sheet).unwrap(); + assert_eq!(sheet.rules.len(), 1000); + assert_eq!(sheet.blocks.len(), 1); + assert_eq!(sheet.decls, 50); +} + +#[test] +fn rules_of_unsupported_selectors_store_no_blocks() { + let mut sheet = StyleSheet::default(); + parse_css(&"a b{}".repeat(100_000), &mut sheet).unwrap(); + parse_css("a b{fill:red} a>b, rect{fill:blue} c:hover, d{fill:red}", &mut sheet).unwrap(); + assert_eq!(sheet.rules.len(), 2); + assert_eq!(sheet.blocks.len(), 2); + assert!(sheet.rules.iter().all(|r| sheet.blocks.get(r.block).is_some_and(|b| b.len() == 1))); +} + +#[test] +fn huge_class_lists_count_against_the_matching_budget() { + // One rule, but a 1 MB class attribute re-split on each of 100 `` copies (#2140). + let class = "a ".repeat(500_000); + let uses: String = (0..100).map(|i| format!(r##""##)).collect(); + let svg = |uses: &str| { + format!( + r##"{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("{uses}"## + ) + }; + let e = parse(svg(&uses).as_bytes()).unwrap_err(); + assert!(e.to_string().contains("