diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 587be4d..5debd5b 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -1,7 +1,7 @@ use crate::engine::animator::ease; use crate::schema::{ EasingType, PanBackground, PixelDissolveOrder, Transition, TransitionCorner, - TransitionDirection, TransitionType, + TransitionDirection, TransitionType, ZoomBlurOrigin, }; use skia_safe::{surfaces, Color4f, ColorType, ImageInfo, Paint, PathBuilder, Rect}; @@ -13,6 +13,8 @@ pub struct TransitionOptions { pub order: PixelDissolveOrder, pub direction: TransitionDirection, pub aberration: f32, + pub strength: f32, + pub origin: Option, } impl Default for TransitionOptions { @@ -24,6 +26,8 @@ impl Default for TransitionOptions { order: PixelDissolveOrder::default(), direction: TransitionDirection::default(), aberration: 1.0, + strength: 1.0, + origin: None, } } } @@ -37,6 +41,8 @@ impl From<&Transition> for TransitionOptions { order: t.order, direction: t.direction, aberration: t.aberration, + strength: t.strength, + origin: t.origin, } } } @@ -58,6 +64,8 @@ pub fn apply_transition( order, direction, aberration, + strength, + origin, } = *opts; match transition_type { @@ -91,6 +99,9 @@ pub fn apply_transition( TransitionType::ChromaticWipe => chromatic_wipe( frame_a, frame_b, width, height, progress, direction, aberration, ), + TransitionType::ZoomBlur => { + zoom_blur_transition(frame_a, frame_b, width, height, progress, strength, origin) + } TransitionType::None => { if progress < 0.5 { frame_a.to_vec() @@ -614,6 +625,83 @@ fn chromatic_wipe( out } +const ZOOM_BLUR_ZOOM_REACH: f32 = 0.5; +const ZOOM_BLUR_STEPS: usize = 10; +const ZOOM_BLUR_MAX_EXTRA_SCALE: f32 = 0.6; + +fn zoom_blur_transition( + frame_a: &[u8], + frame_b: &[u8], + width: u32, + height: u32, + progress: f32, + strength: f32, + origin: Option, +) -> Vec { + let mut surface = match create_skia_surface(width, height) { + Some(s) => s, + None => return blend_fade(frame_a, frame_b, progress), + }; + let (Some(img_a), Some(img_b)) = ( + frame_to_image(frame_a, width, height), + frame_to_image(frame_b, width, height), + ) else { + return blend_fade(frame_a, frame_b, progress); + }; + + let (w, h) = (width as f32, height as f32); + let (ox, oy) = match origin { + Some(o) => (o.x, o.y), + None => (w / 2.0, h / 2.0), + }; + + let scale_now = 1.0 + progress * ZOOM_BLUR_ZOOM_REACH; + let alpha_a = 1.0 - progress; + + { + let canvas = surface.canvas(); + canvas.draw_image(&img_b, (0.0, 0.0), None); + canvas.save(); + canvas.translate((ox, oy)); + canvas.scale((scale_now, scale_now)); + canvas.translate((-ox, -oy)); + let mut paint = Paint::default(); + paint.set_alpha_f(alpha_a); + canvas.draw_image(&img_a, (0.0, 0.0), Some(&paint)); + canvas.restore(); + } + let sharp = surface_to_pixels(surface, width, height); + + let peak = 1.0 - (progress * 2.0 - 1.0).abs(); + let reach = strength.max(0.0) * peak; + if reach <= 0.0 { + return sharp; + } + + let mut streak_surface = match create_skia_surface(width, height) { + Some(s) => s, + None => return sharp, + }; + let extra = reach * ZOOM_BLUR_MAX_EXTRA_SCALE; + let canvas = streak_surface.canvas(); + canvas.draw_image(&img_b, (0.0, 0.0), None); + for i in (0..ZOOM_BLUR_STEPS).rev() { + let t = i as f32 / (ZOOM_BLUR_STEPS - 1) as f32; + let s = scale_now + extra * t; + let weight = (1.0 - t).powf(1.5); + let mut streak_paint = Paint::default(); + streak_paint.set_alpha_f((alpha_a * weight).clamp(0.0, 1.0)); + canvas.save(); + canvas.translate((ox, oy)); + canvas.scale((s, s)); + canvas.translate((-ox, -oy)); + canvas.draw_image(&img_a, (0.0, 0.0), Some(&streak_paint)); + canvas.restore(); + } + + surface_to_pixels(streak_surface, width, height) +} + fn dissolve_transition( frame_a: &[u8], frame_b: &[u8], diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 309223b..409f1c6 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -1096,6 +1096,19 @@ pub enum TransitionDirection { Down, } +/// The centre a `zoom_blur` transition radiates its streaks from, in frame +/// pixels. Absent = frame centre. +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ZoomBlurOrigin { + /// Horizontal centre, in frame pixels. + #[serde(default)] + pub x: f32, + /// Vertical centre, in frame pixels. + #[serde(default)] + pub y: f32, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Transition { @@ -1123,6 +1136,16 @@ pub struct Transition { /// colour flash and leaves a plain fast slide; `2` doubles it. #[serde(default = "default_transition_aberration")] pub aberration: f32, + /// `zoom_blur` only: how far the radial streaks reach. `0` collapses the + /// streak pass entirely, leaving a plain zoom with no smear; higher + /// values pull the outer copies further from `origin`. Ignored by every + /// other transition type. + #[serde(default = "default_transition_strength")] + pub strength: f32, + /// `zoom_blur` only: the centre the streaks radiate from. Ignored by + /// every other transition type. + #[serde(default)] + pub origin: Option, #[serde(default = "default_transition_duration")] pub duration: f64, #[serde(default = "default_transition_easing")] @@ -1173,6 +1196,12 @@ pub enum TransitionType { /// channels at the peak and recombines as it lands — the glitch-flash /// cut. `direction` steers it, `aberration` scales the split. ChromaticWipe, + /// A radial zoom blur: the outgoing frame streaks outward from `origin` + /// while it fades, then the incoming frame is left standing alone — the + /// "tunnel" cut. `strength` sets how far the streaks reach; `0` + /// collapses it to a plain zoom with no smear. Zero at both ends of the + /// transition, so no fringe bleeds into the next scene. + ZoomBlur, None, } @@ -1188,6 +1217,10 @@ fn default_transition_aberration() -> f32 { 1.0 } +fn default_transition_strength() -> f32 { + 1.0 +} + fn default_transition_duration() -> f64 { 0.5 } diff --git a/crates/rustmotion-core/tests/zoom_blur.rs b/crates/rustmotion-core/tests/zoom_blur.rs new file mode 100644 index 0000000..9ab0326 --- /dev/null +++ b/crates/rustmotion-core/tests/zoom_blur.rs @@ -0,0 +1,223 @@ +use rustmotion_core::engine::transition::{apply_transition, TransitionOptions}; +use rustmotion_core::schema::{TransitionType, ZoomBlurOrigin}; + +const W: u32 = 64; +const H: u32 = 48; + +fn solid(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec { + (0..width * height).flat_map(|_| [r, g, b, 255]).collect() +} + +fn split(width: u32, height: u32) -> Vec { + let mut out = Vec::with_capacity((width * height * 4) as usize); + for _y in 0..height { + for x in 0..width { + if x < width / 2 { + out.extend_from_slice(&[240, 240, 240, 255]); + } else { + out.extend_from_slice(&[10, 10, 10, 255]); + } + } + } + out +} + +fn off_centre_stripe(width: u32, height: u32) -> Vec { + let mut out = Vec::with_capacity((width * height * 4) as usize); + let (x0, x1) = (width * 5 / 8, width * 7 / 8); + for _y in 0..height { + for x in 0..width { + if x >= x0 && x < x1 { + out.extend_from_slice(&[240, 240, 240, 255]); + } else { + out.extend_from_slice(&[10, 10, 10, 255]); + } + } + } + out +} + +fn frames() -> (Vec, Vec) { + (solid(W, H, 200, 200, 200), solid(W, H, 40, 40, 40)) +} + +fn opts(strength: f32) -> TransitionOptions { + TransitionOptions { + strength, + ..TransitionOptions::default() + } +} + +fn composite(progress: f64, o: &TransitionOptions) -> Vec { + let (a, b) = frames(); + apply_transition(&a, &b, W, H, progress, &TransitionType::ZoomBlur, o) +} + +fn pixel(buf: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { + let i = ((y * width + x) * 4) as usize; + [buf[i], buf[i + 1], buf[i + 2], buf[i + 3]] +} + +#[test] +fn strength_zero_is_a_plain_zoom_at_every_progress() { + for p in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0] { + let with_strength_zero = composite(p, &opts(0.0)); + let with_strength_zero_again = composite(p, &opts(0.0)); + assert_eq!( + with_strength_zero, with_strength_zero_again, + "must be deterministic at progress {p}" + ); + } + + let (a, b) = frames(); + let plain_zoom_at_start = + apply_transition(&a, &b, W, H, 0.0, &TransitionType::ZoomBlur, &opts(0.0)); + assert_eq!( + plain_zoom_at_start, a, + "strength 0 at progress 0 must be exactly the source frame" + ); + + let plain_zoom_at_end = + apply_transition(&a, &b, W, H, 1.0, &TransitionType::ZoomBlur, &opts(0.0)); + assert_eq!( + plain_zoom_at_end, b, + "strength 0 at progress 1 must be exactly the destination frame" + ); +} + +#[test] +fn nonzero_strength_changes_the_mid_transition_frame() { + let big_strength_at_midpoint = composite(0.5, &opts(5.0)); + let zero_strength_at_midpoint = composite(0.5, &opts(0.0)); + assert_ne!( + big_strength_at_midpoint, zero_strength_at_midpoint, + "a non-zero strength must visibly change the mid-transition frame" + ); +} + +#[test] +fn zero_at_both_ends_even_with_strong_streaks() { + let (a, b) = frames(); + let o = opts(4.0); + + let at_start = apply_transition(&a, &b, W, H, 0.0, &TransitionType::ZoomBlur, &o); + assert_eq!( + at_start, a, + "progress 0 must be pixel-identical to the source frame — no residual smear" + ); + + let at_end = apply_transition(&a, &b, W, H, 1.0, &TransitionType::ZoomBlur, &o); + assert_eq!( + at_end, b, + "progress 1 must be pixel-identical to the destination frame — a leftover streak here \ + would bleed into the next scene" + ); +} + +#[test] +fn mid_transition_a_sharp_edge_is_measurably_smeared() { + let a = off_centre_stripe(W, H); + let b = solid(W, H, 40, 40, 40); + + let sharp = apply_transition(&a, &b, W, H, 0.5, &TransitionType::ZoomBlur, &opts(0.0)); + let blurred = apply_transition(&a, &b, W, H, 0.5, &TransitionType::ZoomBlur, &opts(3.0)); + + let row = H / 2; + let distinct_sharp = (0..W) + .map(|x| pixel(&sharp, W, x, row)[0]) + .collect::>() + .len(); + let distinct_blurred = (0..W) + .map(|x| pixel(&blurred, W, x, row)[0]) + .collect::>() + .len(); + + assert!( + distinct_blurred > distinct_sharp + 2, + "the blurred pass must introduce intermediate values across the edge, away from a \ + stripe that does not sit on the zoom's pivot \ + (sharp had {distinct_sharp} distinct red values, blurred had {distinct_blurred})" + ); +} + +#[test] +fn a_bigger_strength_smears_further() { + let a = off_centre_stripe(W, H); + let b = solid(W, H, 40, 40, 40); + let row = H / 2; + + let front = |strength: f32| -> u32 { + let out = apply_transition( + &a, + &b, + W, + H, + 0.5, + &TransitionType::ZoomBlur, + &opts(strength), + ); + (0..W) + .rev() + .find(|&x| pixel(&out, W, x, row)[0] > 50) + .expect("some part of the stripe must remain visible") + }; + + let sharp_front = front(0.0); + let subtle_front = front(0.3); + let loud_front = front(1.2); + + assert!( + subtle_front > sharp_front, + "a non-zero strength must push the trailing edge of the streak past the plain zoom \ + (sharp front={sharp_front}, subtle front={subtle_front})" + ); + assert!( + loud_front > subtle_front, + "a bigger strength must reach further than a smaller one \ + (subtle front={subtle_front}, loud front={loud_front})" + ); +} + +#[test] +fn custom_origin_shifts_where_the_streaks_radiate_from() { + let a = split(W, H); + let b = solid(W, H, 40, 40, 40); + + let default_origin = TransitionOptions { + strength: 3.0, + ..TransitionOptions::default() + }; + let corner_origin = TransitionOptions { + strength: 3.0, + origin: Some(ZoomBlurOrigin { x: 0.0, y: 0.0 }), + ..TransitionOptions::default() + }; + + let out_default = apply_transition( + &a, + &b, + W, + H, + 0.5, + &TransitionType::ZoomBlur, + &default_origin, + ); + let out_corner = apply_transition(&a, &b, W, H, 0.5, &TransitionType::ZoomBlur, &corner_origin); + + assert_ne!( + out_default, out_corner, + "moving the origin to a corner must change the mid-transition frame" + ); +} + +#[test] +fn deterministic_across_repeated_renders() { + let (a, b) = frames(); + let o = opts(2.0); + let first = apply_transition(&a, &b, W, H, 0.42, &TransitionType::ZoomBlur, &o); + let second = apply_transition(&a, &b, W, H, 0.42, &TransitionType::ZoomBlur, &o); + assert_eq!( + first, second, + "two renders of the same frame must be byte-identical" + ); +} diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 8f2560b..02b9105 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -234,6 +234,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/html-css-mental-model.md](rules/html-css-mental-model.md) - **CRITICAL:** Think HTML/CSS — flow layout first, absolute only for decorative/overlay elements - [rules/validate-json.md](rules/validate-json.md) - Always validate generated JSON with `rustmotion validate` before presenting - [rules/halo-shapes.md](rules/halo-shapes.md) - `halo` beyond circles: `radius_x`/`radius_y`/`rotation` for a wide thin band of light, and why the blur follows the short axis +- [rules/zoom-blur-transition.md](rules/zoom-blur-transition.md) - The radial "tunnel" cut: `zoom_blur`'s `strength`/`origin`, why it had to be a transition and not an effect, and the pivot-coincident-edge trap - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet - [rules/overlapping-scenes.md](rules/overlapping-scenes.md) - Make an element outlive a cut: overlapping `at` windows composite instead of replacing, who supplies the background, and why `snap` never creates an overlap diff --git a/crates/rustmotion/skills/rules/zoom-blur-transition.md b/crates/rustmotion/skills/rules/zoom-blur-transition.md new file mode 100644 index 0000000..724f11e --- /dev/null +++ b/crates/rustmotion/skills/rules/zoom-blur-transition.md @@ -0,0 +1,47 @@ +# Rule: `zoom_blur` — la coupe "tunnel" + +`zoom_blur` est une `transition` (au même titre que `fade`, `slide`, `chromatic_wipe`…) : comme toute transition d'une vue `slide`, elle composite deux frame-buffers **déjà rendus** — aucun élément ne survit à la coupe, seuls les pixels sont mélangés. Voir [rules/motion-path.md](motion-path.md) et la section « Composition » de `CLAUDE.md` pour ce que ça implique. + +L'effet : la scène sortante s'étire radialement vers l'extérieur depuis un point central en s'estompant — l'impression d'être aspiré dans un tunnel — puis la scène entrante apparaît, nette. + +```json +{ + "type": "chromatic_wipe", + "transition": { + "type": "zoom_blur", + "strength": 1.5, + "duration": 0.5 + } +} +``` + +(exemple de placement — `transition` se pose entre deux scènes d'une vue `slide`, comme n'importe quelle autre transition) + +## Ne pas confondre avec `zoom_in` ni avec l'effet `motion_blur` + +- **`zoom_in`** (transition) fait la même bascule d'échelle mais sur une image **nette** : pas de traînée, juste un zoom sec. +- **`motion_blur`** (effet d'animation, `style.animation`) traîne la trajectoire d'un **composant individuel** en accumulant plusieurs échantillons temporels de sa propre animation (`intensity`, `samples`, `shutter` — voir `crates/rustmotion-core/src/schema/video.rs`). Il ne peut rien faire ici : une transition ne voit plus de composants, seulement deux buffers RGBA déjà peints. C'est précisément pour boucher ce trou que `zoom_blur` existe comme **transition** et non comme effet. + +## Champs + +| Champ | Rôle | Défaut | +|---|---|---| +| `strength` | Portée des traînées. `0` supprime le passage de flou et ne laisse qu'un zoom sec — pas de traînée à aucun instant, même à mi-transition. Les valeurs plus grandes tirent les copies externes plus loin de `origin`. | `1.0` | +| `origin` | `{ "x": …, "y": … }`, le point (en pixels du cadre) d'où les traînées rayonnent. Absent = centre du cadre. | absent → centre | +| `duration`, `easing` | Communs à toutes les transitions. | `0.5`, `ease_in_out` | + +`origin` prend la même forme que `CameraOrigin` (`camera.origin`) — deux champs `x`/`y` en pixels, pas de `%`. + +## Zéro aux deux bouts, par construction + +Comme `chromatic_wipe`, l'intensité de l'effet suit une courbe qui vaut zéro à `progress = 0` et à `progress = 1` (`peak = 1 - |2p - 1|`, le même calcul que pour l'aberration chromatique). Le moteur ne se contente pas de laisser cette courbe tendre vers zéro : à `reach <= 0.0` (donc quand `peak == 0`, c'est-à-dire aux deux extrémités, **quelle que soit la valeur de `strength`**), il retourne directement le composite sans traînée — un court-circuit, pas une atténuation flottante qui pourrait laisser un résidu d'arrondi. `progress = 0` rend exactement la frame source, `progress = 1` exactement la frame de destination : rien ne bave sur la scène suivante. + +`strength: 0` prend le même court-circuit à **tout instant** de la transition, pas seulement aux bords — dans ce cas la transition dégénère en un zoom sec identique à `zoom_in`, sans jamais construire la passe de traînées. + +## Comment c'est construit + +Le composite "net" (`sharp`) est un zoom classique : la frame sortante est mise à l'échelle autour de `origin` et s'estompe (`alpha = 1 - progress`) au-dessus de la frame entrante, dessinée pleine et immobile en dessous. Quand `strength` et la position dans la transition l'exigent, une seconde passe redessine la frame sortante une dizaine de fois à des échelles croissantes autour du même `origin`, avec une opacité qui décroît à mesure que l'échelle grandit — la technique suggérée par l'issue d'origine : une somme de copies mises à l'échelle, échantillonnées le long d'un rayon partant du centre. Aucun tirage aléatoire nulle part : deux rendus de la même frame produisent des octets identiques. + +## Piège : un bord qui passe par `origin` ne peut pas se voir flouter + +Le flou radial est une mise à l'échelle autour d'un pivot. Un point qui se trouve exactement sur ce pivot ne bouge sous **aucune** échelle — donc si le contenu qui doit sembler s'étirer a un bord qui coïncide avec `origin` (typiquement : un dégradé pile au centre du cadre, avec `origin` par défaut), ce bord précis restera net quel que soit `strength`. Ce n'est pas un bug du moteur, c'est la géométrie d'un zoom : décale `origin` du point que tu veux voir s'étirer, ou vérifie l'effet sur un contenu qui n'est pas parfaitement centré sur le pivot.