diff --git a/crates/effects/src/ocio/effects.rs b/crates/effects/src/ocio/effects.rs index 0eb7bd330..81ed8d242 100644 --- a/crates/effects/src/ocio/effects.rs +++ b/crates/effects/src/ocio/effects.rs @@ -360,18 +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) 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; } } } @@ -410,7 +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)?; - let r = ctx.params.f("sampleSize").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 e17fd782a..ea2423043 100644 --- a/crates/effects/src/ocio/tests.rs +++ b/crates/effects/src/ocio/tests.rs @@ -397,6 +397,103 @@ fn color_stabilizer_samples_the_reference_at_the_points_reference_positions() { } } +/// 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); + 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(|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]); + } + } + img +} + +/// 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() { + for (w, h) in [(320u32, 180u32), (640, 360)] { + let (bx, by) = (w as f64 * 0.25, h as f64 * 0.5); + 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!(!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}"); + } + } +} + /// Writes `text` to `dir/name` with modification time `secs` (so an edit within the file system's /// time resolution still moves the stamp), returning the path. fn write_dated(dir: &std::path::Path, name: &str, text: &str, secs: u64) -> String { diff --git a/docs/aep-import.md b/docs/aep-import.md index 82efc5045..514646ecc 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -194,7 +194,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, 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 | | Importer block 2026-11-05 (`1300-thirdparty.aep`), plug-ins that ship with After Effects: `CS Color Neutralizer` (CC Color Neutralizer) 19 Pinning, 20 Blend w. Original; `EXtractoR` 1 Channel Info data, 3 Black Point, 4 White Point, 5 UnMult; `IDentifier` 1 Channel Info data, 2 Display, 3 ID | `ec.color.cccolorneutralizer` `pinning`, `blendWOriginal`; `ec.3d.extractor` `red`, `green`, `blue`, `alpha`, `blackPoint`, `whitePoint`, `unMult`; `ec.3d.identifier` `channelType`, `display`, `id` | CC Color Neutralizer: percent (float, valid 100); its correction is the Red / Green / Blue slider of each range (the Unbalance colours are pickers that set them and keep no record), with View and Black / White Point: **EffectCraft's effect has no counterpart, each is reported when stored** (EffectCraft's Unbalance / Balance stay at their identity defaults). EXtractoR: each output channel by the footage channel's name, which EffectCraft's EXtractoR reads by the same name (the default R, G, B; `Z` from our preset), and `(copy)` as the input's own channel of that position (EffectCraft's R, G, B or A; the default Alpha is A); a record of another kind or without a name is reported and R, G, B, A used (`ExtractorChannels`), Black and White Point as stored (float, valid ±3.4e38; a value beyond ±1e9 is reported as damaged data, as for every number), UnMult a checkbox, Clip stays off. IDentifier: Display by name, ID as stored (integer, valid 2 147 483 647); the Channel Info names the chosen channel (`identifier_channel_type`): After Effects lists only the footage's integer (UINT) channels, and with none chosen (the data then says `Object ID`) it leaves the layer unchanged, where EffectCraft draws the footage's object ID channel: **reported**; a chosen channel named `ObjectID` or `MaterialID` (without regard to case, `objectId` in our preset) is the one EffectCraft's IDentifier reads itself (Channel Type Object ID or Material ID), any other name is reported (EffectCraft's IDentifier has no channel choice) | `tests/it/import/effects_thirdparty.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 | | `ADBE Lumetri` by match-name suffix (1200), not by index: -0062 High Dynamic Range; -0063 HDR White; -0007 / -0008 Temperature / Tint; -0020 Saturation; -0011 Exposure; -0012 ... -0016 Contrast, Highlights, Shadows, Whites, Blacks; -0064 HDR Specular; -0026 Look Intensity; -0028 Faded Film; -0029 Sharpen; -0030 Vibrance; -0031 Saturation; -0033 Tint Balance; -0074 Show Mask; -0075 mask style; -0076 Invert Mask; -0081 Denoise; -0082 Blur; -0086 ... -0090 the HSL correction's Temperature, Tint, Contrast, Sharpen, Saturation; -0051 ... -0054 Vignette Amount, Midpoint, Roundness, Feather; -0073 the HSL key data; -0039 the first of the four RGB curves; -0042 Hue vs Sat; -0047 the Shadows, Midtones and Highlights wheels | `highDynamicRange`, `basicCorrection/tone/hdrWhite`, `basicCorrection/whiteBalance/temperature` / `tint`, `basicCorrection/saturation`, `basicCorrection/tone/exposure`, `.../contrast`, `highlights`, `shadows`, `whites`, `blacks`, `hdrSpecular`, `creative/lookIntensity`, `creative/adjustments/fadedFilm`, `sharpen`, `vibrance`, `creativeSaturation`, `tintBalance`, `hslSecondary/key/showMask`, `maskStyle`, `invertMask`, `hslSecondary/refine/denoise`, `blur`, `hslSecondary/correction/*`, `vignette/*`; `hslSecondary/key/hue*`, `saturation*`, `lightness*`; `curves/rgbCurves/master`; `curves/hueSaturationCurves/hueVsSat`; `colorWheels/shadowsWheel`, `midtonesWheel`, `highlightsWheel` | sliders: the stored number x 1 (valid maxima 1000, 300, 300, 7, 150, 200, 150, 100, 100, 300, 150, 100, 1000, 5, 100 as in `pard`); checkboxes and the popup as elsewhere, **also from records in the default state** (`tdsb` 3); the key: hue centre x 360 degrees, range x 360 as the half width, tolerance x 360 as the softness, saturation and lightness centre +- range x 100 percent, tolerance x 100, once a colour was picked; the master curve's points as `x,y` (0..1); Hue vs Sat's points with y as `0.5 + y / 2` (After Effects' y 1 doubles the saturation, EffectCraft's y 1 too); a wheel's hue (degrees) and distance as the HSL colour of that hue with saturation = distance and lightness 0.5 | 1200: all 62 read-backs of mapped controls in sections that act equal after import (min, max, sections, popups); the other 61 lie in sections that do nothing and are reported; keys and the expression on HDR White (`-56-keys`, `-57-expr`); the presets `-60-preset-curves-rgb`, `-61-preset-curves-huesat`, `-62-preset-wheels` | @@ -422,14 +422,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/effects_thirdparty.rs` (#3112): CC Color Neutralizer, EXtractoR and IDentifier built into `synthetic.aep` like the #3110 effects. Render check of the 29 compositions