From 7d53a4f2bd3f2205d4ef036816fbe43c83667c21 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 17:43:42 +0200 Subject: [PATCH 1/2] fix(transition): iris respects origin, gains shape/fill/ring/reverse; add whip iris_transition hardcoded its mask at the frame centre, so a scenario could set `origin` and pass validation while the pixel never moved (#364) - the class of silent-ignore bug this repo treats as a real defect. origin now drives both the mask centre and the coverage radius (computed from the farthest corner *from that point*, not half the frame diagonal). Riding on the same fix, iris gains the rest of the requested vocabulary: shape (circle/pill, the latter stretched by aspect), fill+hold (a solid-colour hold before the next scene fades in), ring (a border traced from the mask's live radius every frame, so it can't lag behind like the old stroked-circle workaround did), and reverse (closes onto origin instead of opening from it). Also adds `whip` (#361): a directional slide whose axis grows a motion-blur streak peaking at the transition's midpoint, collapsing to a plain `slide` via the same hard short-circuit `zoom_blur` uses at strength 0 or at either end - no leftover streak can bleed into the next scene. `direction_vector` and `directional_slide` are pulled out of `chromatic_wipe` so `whip` reuses the same hard-edged slide instead of re-deriving it. --- .../rustmotion-core/src/engine/transition.rs | 378 +++++++++++++++--- crates/rustmotion-core/src/schema/scenario.rs | 96 ++++- crates/rustmotion-core/tests/iris_mask.rs | 198 +++++++++ crates/rustmotion-core/tests/whip.rs | 186 +++++++++ .../skills/rules/iris-transition.md | 63 +++ .../skills/rules/whip-transition.md | 47 +++ 6 files changed, 903 insertions(+), 65 deletions(-) create mode 100644 crates/rustmotion-core/tests/iris_mask.rs create mode 100644 crates/rustmotion-core/tests/whip.rs create mode 100644 crates/rustmotion/skills/rules/iris-transition.md create mode 100644 crates/rustmotion/skills/rules/whip-transition.md diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 5debd5b..51a59ea 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -1,11 +1,14 @@ use crate::engine::animator::ease; +use crate::engine::renderer::{color4f_from_hex, paint_from_hex}; use crate::schema::{ - EasingType, PanBackground, PixelDissolveOrder, Transition, TransitionCorner, - TransitionDirection, TransitionType, ZoomBlurOrigin, + EasingType, IrisRing, IrisShape, PanBackground, PixelDissolveOrder, Transition, + TransitionCorner, TransitionDirection, TransitionType, ZoomBlurOrigin, +}; +use skia_safe::{ + surfaces, Color4f, ColorType, Image, ImageInfo, Paint, PaintStyle, PathBuilder, Rect, }; -use skia_safe::{surfaces, Color4f, ColorType, ImageInfo, Paint, PathBuilder, Rect}; -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct TransitionOptions { pub corner: TransitionCorner, pub cell: f32, @@ -15,6 +18,13 @@ pub struct TransitionOptions { pub aberration: f32, pub strength: f32, pub origin: Option, + pub shape: IrisShape, + pub aspect: f32, + pub fill: Option, + pub hold: f32, + pub ring: Option, + pub reverse: bool, + pub duration: f64, } impl Default for TransitionOptions { @@ -28,6 +38,13 @@ impl Default for TransitionOptions { aberration: 1.0, strength: 1.0, origin: None, + shape: IrisShape::default(), + aspect: 1.0, + fill: None, + hold: 0.0, + ring: None, + reverse: false, + duration: 0.5, } } } @@ -43,6 +60,13 @@ impl From<&Transition> for TransitionOptions { aberration: t.aberration, strength: t.strength, origin: t.origin, + shape: t.shape, + aspect: t.aspect, + fill: t.fill.clone(), + hold: t.hold, + ring: t.ring.clone(), + reverse: t.reverse, + duration: t.duration, } } } @@ -66,7 +90,14 @@ pub fn apply_transition( aberration, strength, origin, - } = *opts; + shape, + aspect, + fill, + hold, + ring, + reverse, + duration, + } = opts.clone(); match transition_type { TransitionType::Fade => blend_fade(frame_a, frame_b, progress), @@ -86,7 +117,21 @@ pub fn apply_transition( } TransitionType::Flip => flip_transition(frame_a, frame_b, width, height, progress), TransitionType::ClockWipe => clock_wipe(frame_a, frame_b, width, height, progress), - TransitionType::Iris => iris_transition(frame_a, frame_b, width, height, progress), + TransitionType::Iris => iris_transition( + frame_a, + frame_b, + width, + height, + progress, + origin, + shape, + aspect, + fill.as_deref(), + hold, + duration, + ring.as_ref(), + reverse, + ), TransitionType::Slide => slide_transition(frame_a, frame_b, width, height, progress), TransitionType::Dissolve => dissolve_transition(frame_a, frame_b, width, height, progress), TransitionType::CornerReveal => { @@ -102,6 +147,9 @@ pub fn apply_transition( TransitionType::ZoomBlur => { zoom_blur_transition(frame_a, frame_b, width, height, progress, strength, origin) } + TransitionType::Whip => whip_transition( + frame_a, frame_b, width, height, progress, strength, direction, + ), TransitionType::None => { if progress < 0.5 { frame_a.to_vec() @@ -493,47 +541,179 @@ fn clock_wipe(frame_a: &[u8], frame_b: &[u8], width: u32, height: u32, progress: surface_to_pixels(surface, width, height) } -fn iris_transition( - frame_a: &[u8], - frame_b: &[u8], +const IRIS_PILL_OVERSHOOT: f32 = 1.45; +const IRIS_PILL_CORNER_FRACTION: f32 = 0.2; + +fn iris_max_radius( + origin: (f32, f32), + width: f32, + height: f32, + shape: IrisShape, + aspect: f32, +) -> f32 { + let fx = origin.0.max(width - origin.0); + let fy = origin.1.max(height - origin.1); + match shape { + IrisShape::Circle => (fx * fx + fy * fy).sqrt(), + IrisShape::Pill => { + let sqrt_aspect = aspect.max(0.05).sqrt(); + let base = (fx / sqrt_aspect).max(fy * sqrt_aspect); + base * IRIS_PILL_OVERSHOOT + } + } +} + +fn iris_mask_path( + origin: (f32, f32), + shape: IrisShape, + aspect: f32, + radius: f32, +) -> skia_safe::Path { + let radius = radius.max(0.0); + let mut builder = PathBuilder::new(); + match shape { + IrisShape::Circle => { + builder.add_circle((origin.0, origin.1), radius, None); + } + IrisShape::Pill => { + let sqrt_aspect = aspect.max(0.05).sqrt(); + let half_w = radius * sqrt_aspect; + let half_h = radius / sqrt_aspect; + let corner = half_w.min(half_h) * IRIS_PILL_CORNER_FRACTION; + let rect = Rect::from_ltrb( + origin.0 - half_w, + origin.1 - half_h, + origin.0 + half_w, + origin.1 + half_h, + ); + let rrect = skia_safe::RRect::new_rect_xy(rect, corner, corner); + builder.add_rrect(rrect, None, None); + } + } + builder.detach() +} + +fn solid_frame(width: u32, height: u32, hex: &str) -> Vec { + let color = color4f_from_hex(hex); + let (r, g, b, a) = ( + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + (color.a * 255.0).round() as u8, + ); + (0..width * height).flat_map(|_| [r, g, b, a]).collect() +} + +#[allow(clippy::too_many_arguments)] +fn iris_composite( + outer: &[u8], + inner: &[u8], width: u32, height: u32, - progress: f32, + origin: (f32, f32), + shape: IrisShape, + aspect: f32, + radius: f32, + ring: Option<&IrisRing>, ) -> Vec { let mut surface = match create_skia_surface(width, height) { Some(s) => s, - None => return blend_fade(frame_a, frame_b, progress), - }; - let img_a = match frame_to_image(frame_a, width, height) { - Some(i) => i, - None => return blend_fade(frame_a, frame_b, progress), + None => return outer.to_vec(), }; - let img_b = match frame_to_image(frame_b, width, height) { - Some(i) => i, - None => return blend_fade(frame_a, frame_b, progress), + let (Some(img_outer), Some(img_inner)): (Option, Option) = ( + frame_to_image(outer, width, height), + frame_to_image(inner, width, height), + ) else { + return outer.to_vec(); }; - let canvas = surface.canvas(); - let w = width as f32; - let h = height as f32; - let cx = w / 2.0; - let cy = h / 2.0; - let max_radius = (w * w + h * h).sqrt() / 2.0; - let radius = max_radius * progress; - - canvas.draw_image(&img_a, (0.0, 0.0), None); - - let mut path = PathBuilder::new(); - path.add_circle((cx, cy), radius, None); + let path = iris_mask_path(origin, shape, aspect, radius); + let canvas = surface.canvas(); + canvas.draw_image(&img_outer, (0.0, 0.0), None); canvas.save(); - canvas.clip_path(&path.detach(), skia_safe::ClipOp::Intersect, true); - canvas.draw_image(&img_b, (0.0, 0.0), None); + canvas.clip_path(&path, skia_safe::ClipOp::Intersect, true); + canvas.draw_image(&img_inner, (0.0, 0.0), None); canvas.restore(); + if let Some(ring) = ring { + if radius > 1.0 { + let mut paint = paint_from_hex(&ring.color); + paint.set_style(PaintStyle::Stroke); + paint.set_stroke_width(ring.width.max(0.0)); + canvas.draw_path(&path, &paint); + } + } + surface_to_pixels(surface, width, height) } +#[allow(clippy::too_many_arguments)] +fn iris_transition( + frame_a: &[u8], + frame_b: &[u8], + width: u32, + height: u32, + progress: f32, + origin: Option, + shape: IrisShape, + aspect: f32, + fill: Option<&str>, + hold: f32, + duration: f64, + ring: Option<&IrisRing>, + reverse: bool, +) -> Vec { + let (w, h) = (width as f32, height as f32); + let origin = match origin { + Some(o) => (o.x, o.y), + None => (w / 2.0, h / 2.0), + }; + let max_radius = iris_max_radius(origin, w, h, shape, aspect); + + let Some(fill_hex) = fill else { + let t = progress.clamp(0.0, 1.0); + let (outer, inner, radius) = if reverse { + (frame_b, frame_a, max_radius * (1.0 - t)) + } else { + (frame_a, frame_b, max_radius * t) + }; + return iris_composite( + outer, inner, width, height, origin, shape, aspect, radius, ring, + ); + }; + + let hold_fraction = if duration > 0.0 { + (hold as f64 / duration).clamp(0.0, 0.9) as f32 + } else { + 0.0 + }; + let remaining = (1.0 - hold_fraction).max(0.0001); + let grow_span = remaining * 0.5; + let reveal_start = grow_span + hold_fraction; + let filled = solid_frame(width, height, fill_hex); + + if progress < grow_span { + let t = (progress / grow_span).clamp(0.0, 1.0); + let (outer, inner, radius): (&[u8], &[u8], f32) = if reverse { + (&filled, frame_a, max_radius * (1.0 - t)) + } else { + (frame_a, &filled, max_radius * t) + }; + return iris_composite( + outer, inner, width, height, origin, shape, aspect, radius, ring, + ); + } + + if progress < reveal_start { + return filled; + } + + let reveal_span = (1.0 - reveal_start).max(0.0001); + let t = ((progress - reveal_start) / reveal_span).clamp(0.0, 1.0); + blend_fade(&filled, frame_b, t) +} + fn slide_transition( frame_a: &[u8], frame_b: &[u8], @@ -564,6 +744,39 @@ fn slide_transition( surface_to_pixels(surface, width, height) } +fn direction_vector(direction: TransitionDirection) -> (f32, f32) { + match direction { + TransitionDirection::Left => (-1.0, 0.0), + TransitionDirection::Right => (1.0, 0.0), + TransitionDirection::Up => (0.0, -1.0), + TransitionDirection::Down => (0.0, 1.0), + } +} + +fn directional_slide( + frame_a: &[u8], + frame_b: &[u8], + width: u32, + height: u32, + progress: f32, + ux: f32, + uy: f32, +) -> Option> { + let mut surface = create_skia_surface(width, height)?; + let (Some(img_a), Some(img_b)) = ( + frame_to_image(frame_a, width, height), + frame_to_image(frame_b, width, height), + ) else { + return None; + }; + let (w, h) = (width as f32, height as f32); + let canvas = surface.canvas(); + let (dx, dy) = (ux * progress * w, uy * progress * h); + canvas.draw_image(&img_a, (dx, dy), None); + canvas.draw_image(&img_b, (dx - ux * w, dy - uy * h), None); + Some(surface_to_pixels(surface, width, height)) +} + fn chromatic_wipe( frame_a: &[u8], frame_b: &[u8], @@ -573,30 +786,11 @@ fn chromatic_wipe( direction: TransitionDirection, aberration: f32, ) -> Vec { - let (w, h) = (width as f32, height as f32); - let (ux, uy) = match direction { - TransitionDirection::Left => (-1.0, 0.0), - TransitionDirection::Right => (1.0, 0.0), - TransitionDirection::Up => (0.0, -1.0), - TransitionDirection::Down => (0.0, 1.0), - }; + let w = width as f32; + let (ux, uy) = direction_vector(direction); - let slid = { - 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 canvas = surface.canvas(); - let (dx, dy) = (ux * progress * w, uy * progress * h); - canvas.draw_image(&img_a, (dx, dy), None); - canvas.draw_image(&img_b, (dx - ux * w, dy - uy * h), None); - surface_to_pixels(surface, width, height) + let Some(slid) = directional_slide(frame_a, frame_b, width, height, progress, ux, uy) else { + return blend_fade(frame_a, frame_b, progress); }; let peak = 1.0 - (progress * 2.0 - 1.0).abs(); @@ -702,6 +896,80 @@ fn zoom_blur_transition( surface_to_pixels(streak_surface, width, height) } +const WHIP_STEPS: usize = 10; +const WHIP_MAX_REACH: f32 = 0.5; + +fn whip_transition( + frame_a: &[u8], + frame_b: &[u8], + width: u32, + height: u32, + progress: f32, + strength: f32, + direction: TransitionDirection, +) -> Vec { + let (ux, uy) = direction_vector(direction); + let Some(sharp) = directional_slide(frame_a, frame_b, width, height, progress, ux, uy) else { + return blend_fade(frame_a, frame_b, progress); + }; + + let peak = 1.0 - (progress * 2.0 - 1.0).abs(); + let reach = strength.max(0.0) * peak; + if reach <= 0.0 { + return sharp; + } + + let (Some(img_a), Some(img_b)) = ( + frame_to_image(frame_a, width, height), + frame_to_image(frame_b, width, height), + ) else { + return sharp; + }; + let Some(img_sharp) = frame_to_image(&sharp, width, height) else { + return sharp; + }; + let mut surface = match create_skia_surface(width, height) { + Some(s) => s, + None => return sharp, + }; + + let (w, h) = (width as f32, height as f32); + let axis_len = if uy == 0.0 { w } else { h }; + let (dx_a, dy_a) = (ux * progress * w, uy * progress * h); + let (dx_b, dy_b) = (dx_a - ux * w, dy_a - uy * h); + let reach_px = reach * axis_len * WHIP_MAX_REACH; + + let alpha_a = (1.0 - progress).clamp(0.0, 1.0); + let alpha_b = progress.clamp(0.0, 1.0); + + let canvas = surface.canvas(); + canvas.draw_image(&img_sharp, (0.0, 0.0), None); + + for i in (0..WHIP_STEPS).rev() { + let t = i as f32 / (WHIP_STEPS - 1) as f32; + let trail = reach_px * t; + let weight = (1.0 - t).powf(1.5); + + let mut paint_a = Paint::default(); + paint_a.set_alpha_f((alpha_a * weight).clamp(0.0, 1.0)); + canvas.draw_image( + &img_a, + (dx_a - ux * trail, dy_a - uy * trail), + Some(&paint_a), + ); + + let mut paint_b = Paint::default(); + paint_b.set_alpha_f((alpha_b * weight).clamp(0.0, 1.0)); + canvas.draw_image( + &img_b, + (dx_b - ux * trail, dy_b - uy * trail), + Some(&paint_b), + ); + } + + surface_to_pixels(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 26fd1f5..b6fae24 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -1174,8 +1174,9 @@ pub enum TransitionDirection { Down, } -/// The centre a `zoom_blur` transition radiates its streaks from, in frame -/// pixels. Absent = frame centre. +/// The centre an `iris` mask grows from (or closes onto), or 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 { @@ -1187,6 +1188,39 @@ pub struct ZoomBlurOrigin { pub y: f32, } +/// The silhouette an `iris` transition's mask takes as it grows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum IrisShape { + /// A plain disc. `aspect` has no effect on this shape. + #[default] + Circle, + /// A stadium — a rounded rectangle whose width-to-height ratio is set by + /// `aspect`. `aspect: 1.0` degenerates to a circle. + Pill, +} + +/// A coloured border traced along an `iris` mask's moving edge. Inside the +/// ring is whichever scene the mask currently reveals, outside it is the one +/// it is covering. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct IrisRing { + /// Ring colour, any CSS colour string. + pub color: String, + /// Ring thickness, in px. + #[serde(default = "default_iris_ring_width")] + pub width: f32, +} + +fn default_iris_ring_width() -> f32 { + 12.0 +} + +fn default_iris_aspect() -> f32 { + 1.0 +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Transition { @@ -1205,8 +1239,9 @@ pub struct Transition { /// `pixel_dissolve` only: which cells turn first. #[serde(default)] pub order: PixelDissolveOrder, - /// Which way a `chromatic_wipe` travels. Ignored by every other type — - /// the `wipe_*`/`slide` family encodes its direction in the type name. + /// Which way a `chromatic_wipe` or `whip` travels. Ignored by every + /// other type — the `wipe_*`/`slide` family encodes its direction in the + /// type name. #[serde(default)] pub direction: TransitionDirection, /// `chromatic_wipe` only: how far the red and cyan channels split at the @@ -1214,16 +1249,45 @@ 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. + /// `zoom_blur` and `whip` only: how far the streak reaches — radially + /// from `origin` for `zoom_blur`, along `direction` for `whip`. `0` + /// collapses the streak pass entirely, leaving a plain zoom or a plain + /// slide with no smear. 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. + /// `zoom_blur` and `iris` only: for `zoom_blur`, the centre the streaks + /// radiate from; for `iris`, the mask's centre. Ignored by every other + /// transition type. #[serde(default)] pub origin: Option, + /// `iris` only: the mask's silhouette. Ignored by every other type. + #[serde(default)] + pub shape: IrisShape, + /// `iris` `pill` only: width-to-height ratio of the stadium as it grows. + /// Ignored by `circle` and by every other transition type. + #[serde(default = "default_iris_aspect")] + pub aspect: f32, + /// `iris` only: a flat colour the mask fills with instead of revealing + /// the next scene directly. Once the colour covers the whole frame, the + /// next scene fades in under it. Absent reveals the next scene straight + /// away. Ignored by every other transition type. + #[serde(default)] + pub fill: Option, + /// `iris` only, and only meaningful with `fill` set: seconds the solid + /// colour holds once it covers the whole frame, before the next scene + /// fades in. Has no effect without `fill`. + #[serde(default)] + pub hold: f32, + /// `iris` only: a coloured ring traced along the mask's moving edge. + /// Ignored by every other transition type. + #[serde(default)] + pub ring: Option, + /// `iris` only: the mask closes onto `origin` instead of opening from + /// it — the outgoing scene shrinks to a point rather than the incoming + /// one (or `fill`) growing to cover the frame. Ignored by every other + /// transition type. + #[serde(default)] + pub reverse: bool, #[serde(default = "default_transition_duration")] pub duration: f64, #[serde(default = "default_transition_easing")] @@ -1264,6 +1328,11 @@ pub enum TransitionType { ZoomOut, Flip, ClockWipe, + /// A mask that grows from `origin` to reveal the next scene — a plain + /// disc by default. `shape` and `aspect` change its silhouette, `fill` + /// paints it a flat colour first (held for `hold` seconds before the + /// next scene fades in under it), `ring` traces its moving edge, and + /// `reverse` closes the mask onto `origin` instead of opening from it. Iris, Slide, Dissolve, @@ -1280,6 +1349,13 @@ pub enum TransitionType { /// 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, + /// A directional slide whose axis grows a motion-blur streak that peaks + /// at the midpoint: the outgoing frame shoots off along `direction` + /// while it fades, and the incoming frame arrives already streaked and + /// lands sharp. `strength` sets how far the streak reaches; `0` + /// collapses it to a plain `slide`. Zero at both ends of the + /// transition, so no streak bleeds into the next scene. + Whip, None, } diff --git a/crates/rustmotion-core/tests/iris_mask.rs b/crates/rustmotion-core/tests/iris_mask.rs new file mode 100644 index 0000000..fad3a77 --- /dev/null +++ b/crates/rustmotion-core/tests/iris_mask.rs @@ -0,0 +1,198 @@ +use rustmotion_core::engine::transition::{apply_transition, TransitionOptions}; +use rustmotion_core::schema::{IrisRing, IrisShape, TransitionType, ZoomBlurOrigin}; + +const W: u32 = 64; +const H: u32 = 48; + +fn frames() -> (Vec, Vec) { + let a: Vec = (0..W * H).flat_map(|_| [200u8, 200, 200, 255]).collect(); + let b: Vec = (0..W * H).flat_map(|_| [40u8, 40, 40, 255]).collect(); + (a, b) +} + +fn opts(origin: Option) -> TransitionOptions { + TransitionOptions { + origin, + ..TransitionOptions::default() + } +} + +fn composite(progress: f64, o: &TransitionOptions) -> Vec { + let (a, b) = frames(); + apply_transition(&a, &b, W, H, progress, &TransitionType::Iris, o) +} + +#[test] +fn a_corner_origin_reveals_that_corner_early_instead_of_the_frame_centre() { + let a: Vec = (0..W * H).flat_map(|_| [5u8, 6, 17, 255]).collect(); + let b: Vec = (0..W * H).flat_map(|_| [225u8, 231, 239, 255]).collect(); + let o = opts(Some(ZoomBlurOrigin { x: 0.0, y: 0.0 })); + + let out = apply_transition(&a, &b, W, H, 0.15, &TransitionType::Iris, &o); + let top_left = &out[0..3]; + assert_eq!( + top_left, + &[225u8, 231, 239], + "with `origin` pinned to the top-left corner, that corner must already show the incoming \ + scene early in the transition — the old hardcoded-centre mask left it on the outgoing \ + scene regardless of `origin`" + ); +} + +#[test] +fn a_corner_origin_changes_the_mid_transition_frame() { + let centre = composite(0.4, &opts(None)); + let corner = composite(0.4, &opts(Some(ZoomBlurOrigin { x: 2.0, y: 2.0 }))); + assert_ne!( + centre, corner, + "moving `origin` to a corner must change which pixels the mask has revealed — `origin` \ + was previously accepted and ignored" + ); +} + +#[test] +fn every_origin_lands_on_the_incoming_frame_at_the_end() { + let (_, b) = frames(); + for origin in [ + None, + Some(ZoomBlurOrigin { x: 0.0, y: 0.0 }), + Some(ZoomBlurOrigin { + x: W as f32, + y: H as f32, + }), + Some(ZoomBlurOrigin { x: 10.0, y: 40.0 }), + ] { + let end = composite(1.0, &opts(origin)); + assert_eq!( + end, b, + "progress 1.0 must show frame B alone, whatever `origin` was set to" + ); + } +} + +#[test] +fn progress_zero_is_the_source_frame_regardless_of_origin() { + let (a, _) = frames(); + let start = composite(0.0, &opts(Some(ZoomBlurOrigin { x: 5.0, y: 5.0 }))); + assert_eq!( + start, a, + "progress 0.0 must show frame A alone, whatever `origin` was set to" + ); +} + +#[test] +fn pill_and_circle_disagree_mid_growth() { + let circle = TransitionOptions { + shape: IrisShape::Circle, + ..TransitionOptions::default() + }; + let pill = TransitionOptions { + shape: IrisShape::Pill, + aspect: 3.0, + ..TransitionOptions::default() + }; + let out_circle = composite(0.3, &circle); + let out_pill = composite(0.3, &pill); + assert_ne!( + out_circle, out_pill, + "a pill mask with a non-1.0 aspect must reveal a different silhouette than a circle" + ); +} + +#[test] +fn fill_holds_a_solid_colour_before_revealing_the_next_scene() { + let o = TransitionOptions { + fill: Some("#00FF00".to_string()), + hold: 0.3, + duration: 0.5, + ..TransitionOptions::default() + }; + let during_hold = composite(0.5, &o); + for px in during_hold.as_chunks::<4>().0 { + assert_eq!( + *px, + [0, 255, 0, 255], + "the whole frame must be the fill colour while the transition holds" + ); + } +} + +#[test] +fn fill_eventually_reveals_the_incoming_scene() { + let (_, b) = frames(); + let o = TransitionOptions { + fill: Some("#00FF00".to_string()), + hold: 0.1, + duration: 0.5, + ..TransitionOptions::default() + }; + assert_eq!( + composite(1.0, &o), + b, + "even with a fill colour and a hold, progress 1.0 must land on frame B" + ); +} + +#[test] +fn without_fill_hold_has_no_effect() { + let with_hold = composite( + 0.5, + &TransitionOptions { + hold: 10.0, + ..TransitionOptions::default() + }, + ); + let without_hold = composite(0.5, &TransitionOptions::default()); + assert_eq!( + with_hold, without_hold, + "`hold` without `fill` must be a no-op" + ); +} + +#[test] +fn ring_paints_a_colour_absent_from_both_scenes() { + let o = TransitionOptions { + ring: Some(IrisRing { + color: "#00FF00".to_string(), + width: 6.0, + }), + ..TransitionOptions::default() + }; + let out = composite(0.5, &o); + let has_green = out + .as_chunks::<4>() + .0 + .iter() + .any(|px| px[1] > 200 && px[0] < 60 && px[2] < 60); + assert!( + has_green, + "a ring colour that appears in neither scene must still show up on screen" + ); +} + +#[test] +fn reverse_changes_which_scene_the_mask_encloses() { + let normal = composite(0.3, &TransitionOptions::default()); + let reversed = composite( + 0.3, + &TransitionOptions { + reverse: true, + ..TransitionOptions::default() + }, + ); + assert_ne!( + normal, reversed, + "`reverse` must change which scene the mask currently encloses" + ); +} + +#[test] +fn reverse_still_starts_on_a_and_ends_on_b() { + let (a, b) = frames(); + let o = TransitionOptions { + reverse: true, + ..TransitionOptions::default() + }; + assert_eq!(composite(0.0, &o), a, "reverse must still start on frame A"); + assert_eq!(composite(1.0, &o), b, "reverse must still end on frame B"); +} diff --git a/crates/rustmotion-core/tests/whip.rs b/crates/rustmotion-core/tests/whip.rs new file mode 100644 index 0000000..b9e310e --- /dev/null +++ b/crates/rustmotion-core/tests/whip.rs @@ -0,0 +1,186 @@ +use rustmotion_core::engine::transition::{apply_transition, TransitionOptions}; +use rustmotion_core::schema::{TransitionDirection, TransitionType}; + +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 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, direction: TransitionDirection) -> TransitionOptions { + TransitionOptions { + strength, + direction, + ..TransitionOptions::default() + } +} + +fn composite(progress: f64, o: &TransitionOptions) -> Vec { + let (a, b) = frames(); + apply_transition(&a, &b, W, H, progress, &TransitionType::Whip, o) +} + +fn slide(progress: f64) -> Vec { + let (a, b) = frames(); + apply_transition( + &a, + &b, + W, + H, + progress, + &TransitionType::Slide, + &TransitionOptions::default(), + ) +} + +#[test] +fn zero_at_both_ends_even_with_a_strong_streak() { + let (a, b) = frames(); + let o = opts(6.0, TransitionDirection::Left); + + let at_start = apply_transition(&a, &b, W, H, 0.0, &TransitionType::Whip, &o); + assert_eq!( + at_start, a, + "progress 0 must be pixel-identical to the source frame — no residual streak" + ); + + let at_end = apply_transition(&a, &b, W, H, 1.0, &TransitionType::Whip, &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 strength_zero_is_a_plain_slide_at_every_progress() { + for p in [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] { + assert_eq!( + composite(p, &opts(0.0, TransitionDirection::Left)), + slide(p), + "with no strength, `whip` must be byte-identical to `slide` at progress {p}" + ); + } +} + +#[test] +fn nonzero_strength_changes_the_mid_transition_frame() { + let with_streak = composite(0.5, &opts(3.0, TransitionDirection::Left)); + let plain = slide(0.5); + assert_ne!( + with_streak, plain, + "a non-zero strength must visibly change the mid-transition frame" + ); +} + +#[test] +fn a_bigger_strength_streaks_further() { + let a = off_centre_stripe(W, H); + let b = solid(W, H, 40, 40, 40); + let row = H / 2; + + let pixel = |buf: &[u8], x: u32| -> u8 { + let i = ((row * W + x) * 4) as usize; + buf[i] + }; + + let front = |strength: f32| -> u32 { + let o = opts(strength, TransitionDirection::Left); + let out = apply_transition(&a, &b, W, H, 0.5, &TransitionType::Whip, &o); + (0..W) + .rev() + .find(|&x| pixel(&out, x) > 50) + .expect("some part of the stripe must remain visible") + }; + + let sharp_front = front(0.0); + let subtle_front = front(1.0); + let loud_front = front(4.0); + + assert!( + subtle_front >= sharp_front, + "a non-zero strength should not pull the trailing edge backward \ + (sharp={sharp_front}, subtle={subtle_front})" + ); + assert!( + loud_front > subtle_front, + "a bigger strength must streak further than a smaller one \ + (subtle={subtle_front}, loud={loud_front})" + ); +} + +#[test] +fn every_direction_lands_on_the_incoming_frame() { + let (_, b) = frames(); + for direction in [ + TransitionDirection::Left, + TransitionDirection::Right, + TransitionDirection::Up, + TransitionDirection::Down, + ] { + let end = composite(1.0, &opts(2.0, direction)); + assert_eq!( + end, b, + "travelling {direction:?}, progress 1.0 must show frame B alone" + ); + } +} + +#[test] +fn each_direction_produces_a_different_mid_frame() { + let a = off_centre_stripe(W, H); + let b = solid(W, H, 40, 40, 40); + let mids: Vec> = [ + TransitionDirection::Left, + TransitionDirection::Right, + TransitionDirection::Up, + TransitionDirection::Down, + ] + .into_iter() + .map(|d| { + let o = opts(2.0, d); + apply_transition(&a, &b, W, H, 0.4, &TransitionType::Whip, &o) + }) + .collect(); + + for i in 0..mids.len() { + for j in (i + 1)..mids.len() { + assert_ne!( + mids[i], mids[j], + "directions {i} and {j} produced the same frame — the direction is being ignored" + ); + } + } +} + +#[test] +fn deterministic_across_repeated_renders() { + let (a, b) = frames(); + let o = opts(3.0, TransitionDirection::Right); + let first = apply_transition(&a, &b, W, H, 0.37, &TransitionType::Whip, &o); + let second = apply_transition(&a, &b, W, H, 0.37, &TransitionType::Whip, &o); + assert_eq!( + first, second, + "two renders of the same frame must be byte-identical" + ); +} diff --git a/crates/rustmotion/skills/rules/iris-transition.md b/crates/rustmotion/skills/rules/iris-transition.md new file mode 100644 index 0000000..11f3145 --- /dev/null +++ b/crates/rustmotion/skills/rules/iris-transition.md @@ -0,0 +1,63 @@ +# Rule: `iris` — le masque qui grandit (ou se referme) depuis `origin` + +`iris` est une `transition` : comme toute transition d'une vue `slide`, elle composite deux frame-buffers **déjà rendus**, ici au travers d'une forme qui grandit depuis `origin` (ou se referme dessus) et révèle la scène suivante. Voir la section « Composition » de `CLAUDE.md`. + +```json +{ + "transition": { + "type": "iris", + "duration": 0.5, + "easing": "ease_in_expo", + "origin": { "x": 460, "y": 160 }, + "shape": "pill", + "aspect": 2.2, + "fill": "#FD2E92", + "hold": 0.1, + "ring": { "color": "#FFFFFF", "width": 6 }, + "reverse": false + } +} +``` + +## Piège corrigé : `origin` était accepté et ignoré + +Avant correction, `iris_transition` codait en dur `cx = w / 2, cy = h / 2` : `origin` passait la validation mais ne changeait jamais un seul pixel — le masque grandissait toujours depuis le centre du cadre. Le rayon maximal (celui qui doit atteindre le coin le plus éloigné) était aussi calculé pour un masque centré, donc systématiquement faux dès que `origin` n'est pas le centre. Les deux sont recalculés maintenant à partir du point réellement demandé : le rayon de couverture dépend de la distance de `origin` au coin le plus éloigné, pas de la diagonale du cadre divisée par deux. + +`origin` prend la même forme que pour `zoom_blur` — `{ "x": …, "y": … }` en pixels du cadre, pas en `%`. Absent, il vaut le centre du cadre, comme avant. + +## Champs + +| Champ | Rôle | Défaut | +|---|---|---| +| `origin` | Centre du masque, en pixels du cadre. Absent = centre du cadre. | absent → centre | +| `shape` | `circle` (disque) ou `pill` (stade — rectangle aux bouts arrondis, étiré par `aspect`). | `circle` | +| `aspect` | `pill` uniquement : rapport largeur/hauteur pendant la croissance. `1.0` dégénère en cercle. Ignoré par `circle`. | `1.0` | +| `fill` | Couleur CSS peinte par le masque au lieu de révéler directement la scène suivante ; celle-ci apparaît en fondu une fois la couleur pleine trame atteinte. Absent = révélation directe. | absent | +| `hold` | Secondes de couleur pleine trame tenues avant le fondu vers la scène suivante. Sans effet si `fill` est absent. | `0` | +| `ring` | `{ "color": …, "width": … }` — un anneau coloré tracé sur le bord courant du masque. | absent | +| `reverse` | Le masque se referme sur `origin` au lieu de s'ouvrir depuis lui. | `false` | +| `duration`, `easing` | Communs à toutes les transitions. | `0.5`, `ease_in_out` | + +## `fill` + `hold` : trois phases dans une seule transition + +Sans `fill`, une seule phase : le masque grandit (ou se referme, avec `reverse`) sur toute la durée, la scène suivante apparaissant directement à l'intérieur. + +Avec `fill`, la transition se découpe en trois segments successifs de `progress` : + +1. **Croissance** — le masque de couleur pleine grandit sur la scène sortante, sur la première moitié du temps qui reste une fois `hold` retiré. +2. **Attente** — l'écran entier est la couleur `fill`, pendant `hold` secondes (converti en fraction de `duration`, plafonné à 90 % pour garder de la place à la croissance et à la révélation). +3. **Révélation** — fondu de la couleur pleine trame vers la scène entrante, sur la seconde moitié du temps restant. + +`hold` est en **secondes**, comme `duration`, pas en fraction de `progress` — c'est pour ça que la transition a besoin de connaître sa propre `duration` pour convertir l'un en l'autre. Sans `fill`, `hold` est un champ ignoré (no-op), pas une erreur : même logique que `aberration` sur un `iris`, ou `cell` sur un `fade`. + +## `reverse` : le masque se referme, il ne s'inverse pas seulement dans le temps + +`reverse` échange à la fois **qui** occupe le masque et **le sens** du rayon : par défaut, la scène sortante (ou `fill`) remplit tout le cadre et le masque grandissant y ouvre une fenêtre vers la scène suivante ; avec `reverse`, c'est la scène sortante qui occupe le masque, et il **rétrécit** jusqu'à un point pendant que la scène suivante (ou `fill`) envahit tout l'espace libéré autour. Les deux lectures démarrent sur la frame sortante et finissent sur l'entrante — seule la géométrie intermédiaire change. + +## `ring` : recalculé à chaque frame depuis `origin`/`shape`/`aspect`/le rayon courant + +Le contournement précédemment nécessaire — un cercle tracé au trait, mis à l'échelle depuis le début de la scène suivante — ne pouvait qu'approximer le rayon réel de l'iris et prenait toujours du retard sur lui. `ring` trace le même chemin que le masque, à l'instant exact où il est peint : il suit donc le rayon (et la silhouette `pill` le cas échéant) sans dérive possible. + +## Piège : `pill` grandit avec une marge de sécurité, pas une géométrie exacte + +Un rectangle aux coins arrondis ne couvre pas ses coins exacts comme un disque couvre les siens — l'arrondi mange un peu de la diagonale. Plutôt que d'inverser cette géométrie précisément pour chaque `aspect`, le rayon maximal d'un `pill` est calculé avec une marge généreuse (~45 % au-delà du strict nécessaire) qui garantit la couverture pour des `aspect` raisonnables (le rapport largeur/hauteur du cadre lui-même borne combien `origin` peut être excentré). Ce n'est pas un souci en pratique — la transition est rapide et la marge invisible à l'écran — mais ça veut dire que la taille exacte du masque à un instant donné n'est pas une formule à inverser pour caler un autre élément dessus ; utiliser `ring` pour ça, justement. diff --git a/crates/rustmotion/skills/rules/whip-transition.md b/crates/rustmotion/skills/rules/whip-transition.md new file mode 100644 index 0000000..4894d67 --- /dev/null +++ b/crates/rustmotion/skills/rules/whip-transition.md @@ -0,0 +1,47 @@ +# Rule: `whip` — le pan flouté directionnel + +`whip` est une `transition` (au même titre que `slide`, `chromatic_wipe`, `zoom_blur`…) : 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 la section « Composition » de `CLAUDE.md`. + +L'effet : un `slide` classique le long d'un axe, dont le déplacement porte un filé de mouvement qui culmine à mi-transition — la scène sortante s'étire en traînée derrière elle le long de `direction` en s'estompant, et la scène entrante arrive déjà striée avant de se stabiliser, nette, à l'arrivée. + +```json +{ + "transition": { + "type": "whip", + "direction": "left", + "strength": 1.5, + "duration": 0.35, + "easing": "ease_in_out_cubic" + } +} +``` + +(exemple de placement — `transition` se pose entre deux scènes d'une vue `slide`, comme n'importe quelle autre transition) + +## Ne pas confondre avec `slide`, `chromatic_wipe` ni l'effet `motion_blur` + +- **`slide`** est le même déplacement, sec, sans traînée : `whip` avec `strength: 0` lui est byte-identique à chaque instant de la transition. +- **`chromatic_wipe`** voyage sur le même axe (`direction` prend les mêmes valeurs), mais son pic est une séparation **chromatique** (rouge/cyan) sur le bord de coupe, pas un filé spatial de la frame entière. +- **`motion_blur`** (effet d'animation, `style.animation`) traîne la trajectoire d'un **composant individuel** en accumulant des échantillons de sa propre animation. Il ne voit rien dans une transition, qui ne dispose plus que de deux buffers RGBA déjà peints — c'est exactement le trou que `whip` bouche côté transition, comme `zoom_blur` l'a fait pour le zoom radial. Voir [rules/zoom-blur-transition.md](zoom-blur-transition.md). + +## Champs + +| Champ | Rôle | Défaut | +|---|---|---| +| `direction` | Axe de déplacement des deux frames, mêmes valeurs que `slide`/`chromatic_wipe` (`left`/`right`/`up`/`down`). | `left` | +| `strength` | Portée de la traînée. `0` supprime la passe de flou et laisse un `slide` sec — pas de traînée à aucun instant, même à mi-transition. Les valeurs plus grandes tirent les copies plus loin derrière leur position courante. | `1.0` | +| `duration`, `easing` | Communs à toutes les transitions. | `0.5`, `ease_in_out` | + +## Zéro aux deux bouts, par construction + +Comme `zoom_blur` et `chromatic_wipe`, l'intensité suit `peak = 1 - |2p - 1|` : nulle à `progress = 0` et à `progress = 1`, quelle que soit `strength`. Le moteur ne laisse pas cette courbe tendre vers zéro : à `reach <= 0.0` (donc `peak == 0`, aux deux bornes), il retourne directement le slide net, sans jamais construire la passe de 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 : la transition dégénère alors en un `slide` sec, sans jamais poser la passe de traînée. + +## Comment c'est construit + +Le socle net (`sharp`) est le même calcul que le `slide` interne de `chromatic_wipe` — factorisé dans `directional_slide` et partagé par les deux transitions : les deux frames pleinement opaques, carrelées côte à côte le long de `direction`, sans aucun mélange alpha. Quand `strength` et la position dans la transition l'exigent, une dizaine de copies translatées de **chaque** frame — la sortante ET l'entrante, contrairement à `zoom_blur` qui ne traîne que la frame sortante — sont redessinées de plus en plus loin derrière leur position courante, à une opacité qui décroît avec la distance. C'est la même somme de copies pondérées que `zoom_blur`, translatée le long d'un axe au lieu d'être mise à l'échelle radialement autour d'un `origin`. + +## Piège : une `strength` élevée fait déborder le fantôme sur l'autre scène + +Les copies translatées sont dessinées sur tout le cadre, pas seulement dans le territoire qui appartient encore à leur propre frame. Une `strength` très élevée fait donc bleeder un fantôme semi-transparent de la scène sortante dans la zone déjà occupée par l'entrante, et réciproquement — c'est voulu, c'est précisément ce qui donne l'impression d'un filé de mouvement qui traverse la coupe plutôt que deux images qui glissent l'une sur l'autre. Si l'effet paraît trop étalé, baisser `strength` plutôt que `duration` : raccourcir la durée ne change rien au pic de `peak`, seulement la vitesse à laquelle la transition le traverse. From 7515819449ae2befedd503f7a40d7beefa524cf1 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 19:11:32 +0200 Subject: [PATCH 2/2] docs(skills): link the iris and whip transition rules into the index --- crates/rustmotion/skills/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 1b07d68..bef8eba 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -239,6 +239,8 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/material-and-light.md](rules/material-and-light.md) - Lit surfaces: `style.material`'s three presets, the scene-wide `light` that makes them agree, and why the material follows the box and not a `shape`'s own geometry - [rules/depth-of-field.md](rules/depth-of-field.md) - Defocus by plane: `camera.focus`/`aperture` on the `style.depth` scale, rack focus by keyframe, and why nothing moves without distinct depths - [rules/text-component-parity.md](rules/text-component-parity.md) - Where `text`, `rich_text` and `gradient_text` disagreed: colour alpha, literal whitespace and baseline, the CSS angle convention and explicit `stops` +- [rules/iris-transition.md](rules/iris-transition.md) - `iris` beyond a centred circle: `origin`, `shape`, `fill`+`hold`, `ring` and `reverse`, and the pill coverage approximation +- [rules/whip-transition.md](rules/whip-transition.md) - The whip cut: a directional slide that streaks **both** frames along its axis, unlike `zoom_blur` which streaks only the outgoing one - [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