From e11164ef6fb18025082ae4b5a68feb6df19f9bf5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 15:37:26 +0100 Subject: [PATCH 1/2] #3125: Color Stabilizer's Sample Size is the full width of the sampled area After Effects 2026 reacts to a square 7 px from the sample point at Sample Size 15 but not at 12, at 17 px from 35 but not at 30, at any layer size: Sample Size is the full width in layer pixels. EffectCraft used it as the half-width. The GPU path shares the sampler. --- crates/effects/src/ocio/effects.rs | 8 +++-- crates/effects/src/ocio/tests.rs | 52 ++++++++++++++++++++++++++++++ docs/aep-import.md | 2 +- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/effects/src/ocio/effects.rs b/crates/effects/src/ocio/effects.rs index 0eb7bd330..e426e22fd 100644 --- a/crates/effects/src/ocio/effects.rs +++ b/crates/effects/src/ocio/effects.rs @@ -360,7 +360,8 @@ pub(super) fn profile_converter(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Color Stabilizer -/// Mean straight colour of a square (half-size `r` layer pixels) around layer point `pt`. +/// Mean straight colour of a square (half-size `r` layer pixels, half the Sample Size) around +/// layer point `pt`. fn sample_mean(b: &Buf, pt: [f64; 2], r: f64) -> [f64; 3] { let (cx, cy) = b.to_px(pt); let rr = (r * b.scale).max(0.5); @@ -410,7 +411,10 @@ fn stabilizer_samples(ctx: &EffectCtx, b: &Buf) -> Option<([[f64; 3]; 3], [[f64; let host = ctx.env.host?; let t_ref = ctx.params.f("referenceFrame"); let reference = host.self_at(t_ref, ctx.env.effect_index)?; - let r = ctx.params.f("sampleSize").max(0.5); + // Sample Size is the full width of the sampled square in layer pixels: After Effects 2026 + // reacts to a bright square 7 px from the point at Sample Size 15 but not at 12, at any + // layer size (#3125), so the half-width is half of it. + let r = (ctx.params.f("sampleSize") / 2.0).max(0.5); let pts = STABILIZER_POINTS.map(|id| ctx.params.v2(id)); let ref_params = host.params_at(t_ref); let ref_pts = match &ref_params { diff --git a/crates/effects/src/ocio/tests.rs b/crates/effects/src/ocio/tests.rs index fe93dd25a..92f832168 100644 --- a/crates/effects/src/ocio/tests.rs +++ b/crates/effects/src/ocio/tests.rs @@ -396,3 +396,55 @@ fn color_stabilizer_samples_the_reference_at_the_points_reference_positions() { assert!((o(x) - (a + b * v(x))).abs() < 1e-3, "x {x}"); } } + +/// After Effects 2026's Sample Size sample (#3125): a horizontal ramp, Stabilize Levels with +/// the Black Point at 25 % of the width, mid-height, and a 6 px white square appearing `d` px +/// to the right of it (near edge `d − 3`). The card at `square: None` is the reference. +fn sample_size_card(w: u32, h: u32, square: Option) -> Image { + let mut img = Image::new(w, h); + let (bx, by) = (w as f64 * 0.25, h as f64 * 0.5); + for y in 0..h { + for x in 0..w { + let (cx, cy) = (x as f64 + 0.5, y as f64 + 0.5); + let mut v = (cx / w as f64) as f32; + if square.is_some_and(|d| (cx - (bx + d)).abs() < 3.0 && (cy - by).abs() < 3.0) { + v = 1.0; + } + img.set(x, y, [v, v, v, 1.0]); + } + } + img +} + +/// #3125: Sample Size is the full width of the sampled area in layer pixels, at any layer size. +/// After Effects reacts to the square at near edge 7 px from Sample Size 15 (not at 12), at +/// 17 px from 35 (not at 30), and never to one at 37 px up to 50. EffectCraft used Sample Size +/// as the half-width and reacted at 12 and 30. +#[test] +fn color_stabilizer_sample_size_is_the_full_width_in_pixels() { + let reacts = |w: u32, h: u32, d: f64, size: f64| { + let host = RefHost(sample_size_card(w, h, None)); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let (bx, by) = (w as f64 * 0.25, h as f64 * 0.5); + let vals = [ + ("stabilize", Value::Enum(1)), + ("blackPoint", Value::Vec2([bx, by])), + ("whitePoint", Value::Vec2([w as f64 * 0.75, by])), + ("sampleSize", num(size)), + ]; + let input = sample_size_card(w, h, Some(d)); + let out = run_fx("ec.color.colorstabilizer", &vals, input.clone(), 0.0, env); + // The change on the right half of the layer, far from the square. + let (x, y) = (w as i64 * 7 / 8, h as i64 / 4); + (out.img.get(x, y)[0] - input.get(x, y)[0]).abs() > 1e-4 + }; + for (w, h) in [(320, 180), (640, 360)] { + assert!(reacts(w, h, 10.0, 15.0), "{w}x{h}: d 10 at 15"); + assert!(!reacts(w, h, 10.0, 12.0), "{w}x{h}: d 10 at 12"); + assert!(reacts(w, h, 20.0, 35.0), "{w}x{h}: d 20 at 35"); + assert!(!reacts(w, h, 20.0, 30.0), "{w}x{h}: d 20 at 30"); + for size in [1.0, 5.0, 12.0, 25.0, 40.0, 50.0] { + assert!(!reacts(w, h, 40.0, size), "{w}x{h}: d 40 at {size}"); + } + } +} diff --git a/docs/aep-import.md b/docs/aep-import.md index ed3ab57f5..c1aee6bac 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -179,7 +179,7 @@ parameter gets the file's value: | `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 | | Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 | | `Cryptomatte` (the Cryptomatte plug-in) 1 Settings data, 2 Output, 3 Selection Mode | `ec.3d.cryptomatte` `layerName`, `selection`; `display`, `matteOnly` | the layer name and the selection text from the data, verbatim (`CryptomatteData`; Layer Name picks the footage's Cryptomatte layer of exactly that name, the Layer popup is the fallback; the `` selection tokens are EffectCraft's selection grammar); Output Colors -> Display Colors, Matted RGBA -> Display Matte, Matte Only -> Matte Only on (Display Matte); **Matted Colors** (the ID colours keyed by the selection) has no counterpart: imported as Display Matte (the same matte over the layer's own colours) and reported; no Output record is the plug-in's default Colors (also for a borrowed definition; Colors too for an Output that is keyed, has an expression or cannot be read, each reported); Selection Mode (the plug-in's picking switch) is reported when on, also in the default-state record After Effects writes for it; keys or expressions on any of them are reported; unreadable data is damaged data (no layer, no selection). **Render-compared** with After Effects (1400): with a chosen layer and a picked object (the two preset compositions, Output Colors) EffectCraft's matte covers exactly the pixels After Effects draws as selected (6 012 fully covered for the box on CryptoObject, 10 962 for box and disc on CryptoMaterial, the same counts; on the 190 edge pixels of the latter the coverage implied by After Effects' frame is EffectCraft's within 0.0002); After Effects' Colors draws the selection as `c * (1 - m) + m` (a fully selected pixel white), EffectCraft's Colors as `c * (1 - m / 2) + m / 2`, and the ID colours themselves differ, so Colors is not compared per pixel. With the fresh data (empty selection, every matte 0): Matted RGBA matches (transparent); Matte Only differs in alpha (After Effects: the matte in RGB, opaque; EffectCraft: the matte in RGBA, so the same over black and as a luma matte, not as an alpha matte); Matted Colors is transparent in After Effects and in the imported Matte; Colors is a preview of the IDs and not compared per pixel (After Effects' colours are near black and, with no layer chosen, show the footage's first Cryptomatte layer, CryptoMaterial in 1400, where EffectCraft's Layer popup starts at CryptoObject); what Selection Mode draws is not reproduced | aep-format 3.7 (88 instances of one project; 1400); test `import::cryptomatte` | -| Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; its unit, pixels here, is not verified). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below | +| Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; the full width of the sampled square in layer pixels, measured in After Effects 2026, #3125). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below | | `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 | - **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons From 677c3e40dfe55804cbfd2a47a25f49c26045a7ed Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 16:02:06 +0100 Subject: [PATCH 2/2] #3125: Color Stabilizer samples a square centred on the pixel, with partial pixels After Effects 2026 measurements (320x180 ramp, Levels, Black Point at (80, 90), a 6 px square appearing): a square 7 px to the right or below reacts from Sample Size 13.5, one 7 px to the left or above only from 15.5, so the area is centred half a pixel right of and below the layer point. Diagonal squares react at 16, 26 and 38 (corner 7, 10, 14 px on both axes), which only a square area allows. Sub-pixel Black Points move the threshold continuously and the response grows in 0.5 steps, so pixels count with the share the area covers. The sampler now does that. Re-run of the importer block: the Color Stabilizer drift above Sample Size 25 is gone. --- crates/effects/src/ocio/effects.rs | 33 +++++---- crates/effects/src/ocio/tests.rs | 107 ++++++++++++++++++++--------- docs/aep-import.md | 14 ++-- 3 files changed, 105 insertions(+), 49 deletions(-) diff --git a/crates/effects/src/ocio/effects.rs b/crates/effects/src/ocio/effects.rs index e426e22fd..81ed8d242 100644 --- a/crates/effects/src/ocio/effects.rs +++ b/crates/effects/src/ocio/effects.rs @@ -360,19 +360,30 @@ pub(super) fn profile_converter(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---- Color Stabilizer -/// Mean straight colour of a square (half-size `r` layer pixels, half the Sample Size) around -/// layer point `pt`. -fn sample_mean(b: &Buf, pt: [f64; 2], r: f64) -> [f64; 3] { - let (cx, cy) = b.to_px(pt); - let rr = (r * b.scale).max(0.5); - let (x0, x1) = ((cx - rr).floor().max(0.0) as i64, ((cx + rr).ceil() as i64).min(b.img.width as i64)); - let (y0, y1) = ((cy - rr).floor().max(0.0) as i64, ((cy + rr).ceil() as i64).min(b.img.height as i64)); +/// Mean straight colour of Color Stabilizer's sampled area for layer point `pt`, as After Effects +/// 2026 samples it (#3125): a square `size` layer pixels wide (Sample Size is the full width) +/// centred on the middle of the pixel the point names, i.e. half a pixel right of and below the +/// layer point (After Effects' point (x, y) is the centre of pixel (x, y)). Each pixel counts +/// with the share of it the square covers, so the mean changes smoothly with a sub-pixel point +/// or a fractional size. At least one buffer pixel wide. +fn sample_mean(b: &Buf, pt: [f64; 2], size: f64) -> [f64; 3] { + let (cx, cy) = b.to_px([pt[0] + 0.5, pt[1] + 0.5]); + let h = (size * 0.5 * b.scale).max(0.5); + // The share of pixel `i` (covering [i, i + 1]) inside [c − h, c + h]. + let cover = |i: i64, c: f64| ((i as f64 + 1.0).min(c + h) - (i as f64).max(c - h)).max(0.0); + let (x0, x1) = ((cx - h).floor().max(0.0) as i64, ((cx + h).ceil() as i64).min(b.img.width as i64)); + let (y0, y1) = ((cy - h).floor().max(0.0) as i64, ((cy + h).ceil() as i64).min(b.img.height as i64)); let mut acc = [0.0f64; 4]; for y in y0..y1 { + let wy = cover(y, cy); for x in x0..x1 { + let w = wy * cover(x, cx); + if w <= 0.0 { + continue; + } let p = b.img.get(x, y); for k in 0..4 { - acc[k] += p[k] as f64; + acc[k] += w * p[k] as f64; } } } @@ -411,10 +422,8 @@ fn stabilizer_samples(ctx: &EffectCtx, b: &Buf) -> Option<([[f64; 3]; 3], [[f64; let host = ctx.env.host?; let t_ref = ctx.params.f("referenceFrame"); let reference = host.self_at(t_ref, ctx.env.effect_index)?; - // Sample Size is the full width of the sampled square in layer pixels: After Effects 2026 - // reacts to a bright square 7 px from the point at Sample Size 15 but not at 12, at any - // layer size (#3125), so the half-width is half of it. - let r = (ctx.params.f("sampleSize") / 2.0).max(0.5); + // Sample Size is the full width of the sampled square in layer pixels (#3125). + let r = ctx.params.f("sampleSize").max(1.0); let pts = STABILIZER_POINTS.map(|id| ctx.params.v2(id)); let ref_params = host.params_at(t_ref); let ref_pts = match &ref_params { diff --git a/crates/effects/src/ocio/tests.rs b/crates/effects/src/ocio/tests.rs index 92f832168..e0b515dd7 100644 --- a/crates/effects/src/ocio/tests.rs +++ b/crates/effects/src/ocio/tests.rs @@ -397,17 +397,16 @@ fn color_stabilizer_samples_the_reference_at_the_points_reference_positions() { } } -/// After Effects 2026's Sample Size sample (#3125): a horizontal ramp, Stabilize Levels with -/// the Black Point at 25 % of the width, mid-height, and a 6 px white square appearing `d` px -/// to the right of it (near edge `d − 3`). The card at `square: None` is the reference. -fn sample_size_card(w: u32, h: u32, square: Option) -> Image { +/// After Effects 2026's Sample Size samples (#3125): a horizontal ramp, Stabilize Levels, and a +/// 6 px white square centred at `square` that appears after the reference frame (the card at +/// `square: None` is the reference). +fn sample_size_card(w: u32, h: u32, square: Option<[f64; 2]>) -> Image { let mut img = Image::new(w, h); - let (bx, by) = (w as f64 * 0.25, h as f64 * 0.5); for y in 0..h { for x in 0..w { let (cx, cy) = (x as f64 + 0.5, y as f64 + 0.5); let mut v = (cx / w as f64) as f32; - if square.is_some_and(|d| (cx - (bx + d)).abs() < 3.0 && (cy - by).abs() < 3.0) { + if square.is_some_and(|s| (cx - s[0]).abs() < 3.0 && (cy - s[1]).abs() < 3.0) { v = 1.0; } img.set(x, y, [v, v, v, 1.0]); @@ -416,35 +415,81 @@ fn sample_size_card(w: u32, h: u32, square: Option) -> Image { img } -/// #3125: Sample Size is the full width of the sampled area in layer pixels, at any layer size. -/// After Effects reacts to the square at near edge 7 px from Sample Size 15 (not at 12), at -/// 17 px from 35 (not at 30), and never to one at 37 px up to 50. EffectCraft used Sample Size -/// as the half-width and reacted at 12 and 30. +/// Whether Color Stabilizer (Levels, Black Point at `bp`, Sample Size `size`) changes the +/// `w` × `h` card when the square at `square` appears: the change is read on the right half of +/// the layer, far from the square. +fn stabilizer_reacts(w: u32, h: u32, square: [f64; 2], bp: [f64; 2], size: f64) -> bool { + let host = RefHost(sample_size_card(w, h, None)); + let env = EffectEnv { host: Some(&host), ..Default::default() }; + let vals = [ + ("stabilize", Value::Enum(1)), + ("blackPoint", Value::Vec2(bp)), + ("whitePoint", Value::Vec2([w as f64 * 0.85, h as f64 * 0.5])), + ("sampleSize", num(size)), + ]; + let input = sample_size_card(w, h, Some(square)); + let out = run_fx("ec.color.colorstabilizer", &vals, input.clone(), 0.0, env); + let (x, y) = (w as i64 * 7 / 8, h as i64 / 4); + (out.img.get(x, y)[0] - input.get(x, y)[0]).abs() > 1e-5 +} + +/// #3125: Sample Size is the full width of the sampled square in layer pixels, at any layer +/// size. After Effects 2026 (320 × 180 and 640 × 360, Black Point at 25 % width, mid-height) +/// reacts to a square whose near edge is 7 px to the right of the point from Sample Size 13.5 +/// (not at 13), to one 17 px away by 35, and never to one 37 px away up to 50. EffectCraft used +/// Sample Size as the half-width and reacted at 12 and 30. #[test] fn color_stabilizer_sample_size_is_the_full_width_in_pixels() { - let reacts = |w: u32, h: u32, d: f64, size: f64| { - let host = RefHost(sample_size_card(w, h, None)); - let env = EffectEnv { host: Some(&host), ..Default::default() }; + for (w, h) in [(320u32, 180u32), (640, 360)] { let (bx, by) = (w as f64 * 0.25, h as f64 * 0.5); - let vals = [ - ("stabilize", Value::Enum(1)), - ("blackPoint", Value::Vec2([bx, by])), - ("whitePoint", Value::Vec2([w as f64 * 0.75, by])), - ("sampleSize", num(size)), - ]; - let input = sample_size_card(w, h, Some(d)); - let out = run_fx("ec.color.colorstabilizer", &vals, input.clone(), 0.0, env); - // The change on the right half of the layer, far from the square. - let (x, y) = (w as i64 * 7 / 8, h as i64 / 4); - (out.img.get(x, y)[0] - input.get(x, y)[0]).abs() > 1e-4 - }; - for (w, h) in [(320, 180), (640, 360)] { - assert!(reacts(w, h, 10.0, 15.0), "{w}x{h}: d 10 at 15"); - assert!(!reacts(w, h, 10.0, 12.0), "{w}x{h}: d 10 at 12"); - assert!(reacts(w, h, 20.0, 35.0), "{w}x{h}: d 20 at 35"); - assert!(!reacts(w, h, 20.0, 30.0), "{w}x{h}: d 20 at 30"); + let r = |dx: f64, size: f64| stabilizer_reacts(w, h, [bx + dx, by], [bx, by], size); + assert!(r(10.0, 13.5), "{w}x{h}: d 10 at 13.5"); + assert!(!r(10.0, 13.0), "{w}x{h}: d 10 at 13"); + assert!(!r(10.0, 12.0), "{w}x{h}: d 10 at 12"); + assert!(r(20.0, 35.0), "{w}x{h}: d 20 at 35"); + assert!(!r(20.0, 33.0), "{w}x{h}: d 20 at 33"); + assert!(!r(20.0, 30.0), "{w}x{h}: d 20 at 30"); for size in [1.0, 5.0, 12.0, 25.0, 40.0, 50.0] { - assert!(!reacts(w, h, 40.0, size), "{w}x{h}: d 40 at {size}"); + assert!(!r(40.0, size), "{w}x{h}: d 40 at {size}"); + } + } +} + +/// #3125: the sampled area is a square centred on the middle of the pixel the point names, half a +/// pixel right of and below EffectCraft's layer point. After Effects 2026 with the Black Point at +/// (80, 90) on a 320 × 180 card: a square 7 px to the right or below reacts from 13.5 (not at +/// 13), one 7 px to the left or above only from 15.5 (not at 15). Diagonally, with the square's +/// near corner 7, 10 and 14 px away on both axes, it reacts at 16, 26 and 38 and not at 13, 19 +/// and 27: a square area (a circle of that width would first react at 18.4, 26.9 and 38.2). +#[test] +fn color_stabilizer_samples_a_square_centred_on_the_pixel() { + let r = |square: [f64; 2], size: f64| stabilizer_reacts(320, 180, square, [80.0, 90.0], size); + for (square, no, yes) in [ + ([90.0, 90.0], 13.0, 13.5), + ([80.0, 100.0], 13.0, 13.5), + ([70.0, 90.0], 15.0, 15.5), + ([80.0, 80.0], 15.0, 15.5), + ([90.0, 100.0], 13.0, 16.0), + ([93.0, 103.0], 19.0, 26.0), + ([97.0, 107.0], 27.0, 38.0), + ] { + assert!(!r(square, no), "{square:?} at {no}"); + assert!(r(square, yes), "{square:?} at {yes}"); + } +} + +/// #3125: sub-pixel Black Points. After Effects 2026, square near edge at x 87 (or y 97): with +/// the point at x (or y) + 0.25 it reacts from 13 (not 12.5), + 0.5 from 12.5 (not 12), + 0.75 +/// already at 12; each pixel counts with the share of it the area covers. +#[test] +fn color_stabilizer_sub_pixel_points_cover_partial_pixels() { + for (f, no, yes) in [(0.25, Some(12.5), 13.0), (0.5, Some(12.0), 12.5), (0.75, None, 12.0)] { + for (square, bp) in [([90.0, 90.0], [80.0 + f, 90.0]), ([80.0, 100.0], [80.0, 90.0 + f])] { + let r = |size: f64| stabilizer_reacts(320, 180, square, bp, size); + if let Some(no) = no { + assert!(!r(no), "{bp:?} at {no}"); + } + assert!(r(yes), "{bp:?} at {yes}"); } } } diff --git a/docs/aep-import.md b/docs/aep-import.md index c1aee6bac..f6f618c2d 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -179,7 +179,7 @@ parameter gets the file's value: | `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 | | Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 | | `Cryptomatte` (the Cryptomatte plug-in) 1 Settings data, 2 Output, 3 Selection Mode | `ec.3d.cryptomatte` `layerName`, `selection`; `display`, `matteOnly` | the layer name and the selection text from the data, verbatim (`CryptomatteData`; Layer Name picks the footage's Cryptomatte layer of exactly that name, the Layer popup is the fallback; the `` selection tokens are EffectCraft's selection grammar); Output Colors -> Display Colors, Matted RGBA -> Display Matte, Matte Only -> Matte Only on (Display Matte); **Matted Colors** (the ID colours keyed by the selection) has no counterpart: imported as Display Matte (the same matte over the layer's own colours) and reported; no Output record is the plug-in's default Colors (also for a borrowed definition; Colors too for an Output that is keyed, has an expression or cannot be read, each reported); Selection Mode (the plug-in's picking switch) is reported when on, also in the default-state record After Effects writes for it; keys or expressions on any of them are reported; unreadable data is damaged data (no layer, no selection). **Render-compared** with After Effects (1400): with a chosen layer and a picked object (the two preset compositions, Output Colors) EffectCraft's matte covers exactly the pixels After Effects draws as selected (6 012 fully covered for the box on CryptoObject, 10 962 for box and disc on CryptoMaterial, the same counts; on the 190 edge pixels of the latter the coverage implied by After Effects' frame is EffectCraft's within 0.0002); After Effects' Colors draws the selection as `c * (1 - m) + m` (a fully selected pixel white), EffectCraft's Colors as `c * (1 - m / 2) + m / 2`, and the ID colours themselves differ, so Colors is not compared per pixel. With the fresh data (empty selection, every matte 0): Matted RGBA matches (transparent); Matte Only differs in alpha (After Effects: the matte in RGB, opaque; EffectCraft: the matte in RGBA, so the same over black and as a luma matte, not as an alpha matte); Matted Colors is transparent in After Effects and in the imported Matte; Colors is a preview of the IDs and not compared per pixel (After Effects' colours are near black and, with no layer chosen, show the footage's first Cryptomatte layer, CryptoMaterial in 1400, where EffectCraft's Layer popup starts at CryptoObject); what Selection Mode draws is not reproduced | aep-format 3.7 (88 instances of one project; 1400); test `import::cryptomatte` | -| Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; the full width of the sampled square in layer pixels, measured in After Effects 2026, #3125). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below | +| Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; the full width of the sampled square in layer pixels, centred on the middle of the pixel the point names, measured in After Effects 2026, #3125). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below | | `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 | - **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons @@ -343,14 +343,16 @@ ranges of Lumetri Color among them), are those of the 2026 plug-in in all three None, 0.5 s) against After Effects' frames (its `saveFrameToPng` of a 32 bpc project writes `floor(value x 25)` per channel, so values are compared at that quantisation): Offset (5), Linear Wipe defaults / min / max, Echo with the Add, Maximum, Minimum, Screen operators and the keyed and - expression variants, Color Stabilizer with every Stabilize item and Sample Size up to 25 match - within one level. Differences, all in EffectCraft's effects and not in the mapping: HDR + expression variants, Color Stabilizer with every Stabilize item and every Sample Size (1 to 40) + match within one level. Differences, all in EffectCraft's effects and not in the mapping: HDR Highlight Compression's curve (up to 6 of 25 levels on 19 % of the pixels at Amount 100, equal at Amount 0); Echo's Blend operator (After Effects' frame is as bright as Add's, EffectCraft averages) and the edge of the moving square with Composite In Back / Front (0.5 % of the - pixels); Linear Wipe's feathered edge (0.6 % of the pixels); Color Stabilizer with a Sample Size - over 25 (EffectCraft's samples drift, After Effects' frame stays unchanged) and with keyed - sample points. The renders do not exercise a Decay above 1 (After Effects' set value has + pixels); Linear Wipe's feathered edge (0.6 % of the pixels); Color Stabilizer with keyed sample points. + (Color Stabilizer above Sample Size 25 drifted while EffectCraft used Sample Size as the + half-width; with the full width, #3125, the share of pixels one level off stays at the 19 % of + the unchanged default composition, where it rose to 42 % at 40.) + The renders do not exercise a Decay above 1 (After Effects' set value has Starting Intensity 0) or what Color Stabilizer corrects (the test card does not flicker). - `crates/aep/tests/it/import/combined.rs`: masks and effects on the same layer, an effect on a text layer, and the same through `.aepx` (built in-test from the fixtures: the effect parade of one