diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index ea402d3..01d84a5 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -2087,6 +2087,8 @@ mod tests { stagger: None, fill: None, stroke: None, + draw_start: None, + path_morph: None, }), position: None, x: None, @@ -2224,6 +2226,8 @@ mod tests { stagger: None, fill: None, stroke: None, + draw_start: None, + path_morph: None, }), position: Some(crate::PositionMode::Absolute { x: 40.0, y: 30.0 }), x: None, @@ -3008,6 +3012,8 @@ mod tests { stagger: None, fill: None, stroke: None, + draw_start: None, + path_morph: None, }), position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, @@ -3072,6 +3078,8 @@ mod tests { stagger: None, fill: None, stroke: None, + draw_start: None, + path_morph: None, }), position: Some(crate::PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 23c92d7..ff3b716 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -147,6 +147,8 @@ mod tests { stagger: None, fill: Some(rustmotion_core::schema::Fill::Solid("#ff0000".into())), stroke: None, + draw_start: None, + path_morph: None, }), position: Some(PositionMode::Absolute { x, y }), x: None, @@ -297,6 +299,8 @@ mod tests { stagger: None, fill: Some(rustmotion_core::schema::Fill::Solid("#ff0000".into())), stroke: None, + draw_start: None, + path_morph: None, }), position: Some(PositionMode::Absolute { x: 10.0, y: 10.0 }), x: None, @@ -408,6 +412,8 @@ mod tests { stagger: None, fill: Some(rustmotion_core::schema::Fill::Solid("#ff0000".into())), stroke: None, + draw_start: None, + path_morph: None, }), position: Some(PositionMode::Absolute { x: 0.0, y: 0.0 }), x: None, diff --git a/crates/rustmotion-components/src/shape.rs b/crates/rustmotion-components/src/shape.rs index 78b22ae..2b375eb 100644 --- a/crates/rustmotion-components/src/shape.rs +++ b/crates/rustmotion-components/src/shape.rs @@ -4,16 +4,17 @@ use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, Paint, PaintStyle, Point}; use rustmotion_core::css::{CssStyle, FrameClock}; -use rustmotion_core::engine::animator::AnimatedProperties; +use rustmotion_core::engine::animator::{ease, AnimatedProperties}; use rustmotion_core::engine::layout_pass::BoxLayout; use rustmotion_core::engine::renderer::{ build_shape_path, color4f_from_hex, draw_shape_path, draw_text_with_fallback, emoji_typeface, - measure_text_with_fallback, paint_from_hex, typeface_with_fallback, wrap_text_with_tracking, + interpolate_path_data, measure_text_with_fallback, paint_from_hex, trim_path_between, + typeface_with_fallback, wrap_text_with_tracking, }; use rustmotion_core::expr::{Computed, Expr, Scope}; use rustmotion_core::schema::{ - Fill, FontWeight, GradientType, LineCap, LineJoin, ShapeText, ShapeType, Stroke, TextAlign, - TimelineStep, + EasingType, Fill, FontWeight, GradientType, LineCap, LineJoin, ShapeText, ShapeType, Stroke, + TextAlign, TimelineStep, }; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; @@ -34,6 +35,54 @@ pub struct Shape { pub fill: Option, #[serde(default)] pub stroke: Option, + /// Where the visible stroke starts, as a fraction `0..1` of the path's own + /// length — the counterpart to `draw_progress`, which only ever moves the + /// end. The visible segment is the path between `draw_start` and + /// `draw_progress`; a literal number or an `= expr` evaluated against + /// `t`/`t_abs`/`duration`/`width`/`height`/`fps`, the same scope + /// `dash_offset` reads. Has no effect unless `draw_progress` is also + /// animating (or has already finished, in which case the segment simply + /// erases from its tail) — matching `hold` on `iris`, this is a no-op by + /// itself. Applied only to `stroke`; ignored for `fill`. + #[serde(default)] + pub draw_start: Option>, + /// Keyframes on the shape's own path data (`d`), interpolated point by + /// point when every keyframe shares the same command structure. A + /// mismatched pair is reported on stderr and holds the earlier + /// keyframe's shape rather than snapping — see + /// `rustmotion_core::engine::renderer::interpolate_path_data`. Overrides + /// `shape` entirely (for both `fill` and `stroke`) while present. + #[serde(default)] + pub path_morph: Option, +} + +/// A path's `d` animated over time by re-drawing the interpolated outline +/// between two authored keyframes, rather than a single frozen shape. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct PathMorph { + /// At least one keyframe; a single keyframe holds that shape statically. + pub keyframes: Vec, + /// Applied within each segment between two consecutive keyframes. + #[serde(default)] + pub easing: EasingType, + /// Loops back to the first keyframe once `time` passes the last one, + /// instead of holding the last shape forever. + #[serde(default)] + pub repeat: bool, + /// Only with `repeat`: alternates direction each cycle instead of + /// snapping back to the first keyframe. + #[serde(default)] + pub yoyo: bool, +} + +/// One stop in a `path_morph`. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct PathMorphKeyframe { + /// Seconds from scene start, like every other keyframe `time` in this + /// schema — not a `0..1` fraction. + pub time: f64, + /// SVG path data, same grammar as `shape: { "type": "path", "data": … }`. + pub value: String, } rustmotion_core::impl_traits!(Shape { @@ -80,6 +129,61 @@ fn resolve_dash_offset(offset: &Option>, scope: &dyn Scope) -> f32 } } +fn resolve_draw_start(value: &Option>, scope: &dyn Scope) -> f32 { + let raw = match value { + None => 0.0, + Some(Computed::Literal(v)) => *v, + Some(Computed::Expr(src)) => Expr::parse(src) + .and_then(|e| e.eval(scope)) + .map(|v| v as f32) + .unwrap_or(0.0), + }; + raw.clamp(0.0, 1.0) +} + +fn resolve_path_morph(morph: &PathMorph, time: f64) -> Option { + let keyframes = &morph.keyframes; + let first = keyframes.first()?; + if keyframes.len() == 1 { + return skia_safe::Path::from_svg(&first.value); + } + let last = keyframes.last()?; + let first_time = first.time; + let last_time = last.time; + let span = (last_time - first_time).max(1e-9); + + let sample_time = if morph.repeat && time > first_time { + let elapsed = time - first_time; + let cycle = elapsed.rem_euclid(span); + let forward = elapsed.div_euclid(span) as i64 % 2 == 0; + if morph.yoyo && !forward { + last_time - cycle + } else { + first_time + cycle + } + } else { + time.clamp(first_time, last_time) + }; + + let mut lower = first; + let mut upper = last; + for pair in keyframes.windows(2) { + if sample_time >= pair[0].time && sample_time <= pair[1].time { + lower = &pair[0]; + upper = &pair[1]; + break; + } + } + + if lower.value == upper.value { + return skia_safe::Path::from_svg(&lower.value); + } + let segment_span = (upper.time - lower.time).max(1e-9); + let local_t = ((sample_time - lower.time) / segment_span).clamp(0.0, 1.0); + let eased = ease(local_t, &morph.easing) as f32; + interpolate_path_data(&lower.value, &upper.value, eased) +} + fn resolve_template(s: &str, scope: &dyn Scope) -> std::result::Result, ()> { if !s.contains("${") { return Ok(None); @@ -142,6 +246,10 @@ impl Painter for Shape { } } let shape = resolved_path.as_ref().unwrap_or(&self.shape); + let morphed_path = self + .path_morph + .as_ref() + .and_then(|morph| resolve_path_morph(morph, clock.t)); if let Some(fill) = &self.fill { let paint: Option = match fill { @@ -214,7 +322,12 @@ impl Painter for Shape { }; if let Some(mut paint) = paint { paint.set_style(PaintStyle::Fill); - draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint); + match &morphed_path { + Some(path) => { + canvas.draw_path(path, &paint); + } + None => draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint), + }; } } @@ -230,11 +343,26 @@ impl Painter for Shape { paint.set_stroke_cap(skia_line_cap(stroke.line_cap)); paint.set_stroke_join(skia_line_join(stroke.line_join)); + let draw_start = resolve_draw_start(&self.draw_start, &clock); + let mut trimmed_path = None; + if let Some(intervals) = stroke.dashed.as_ref().filter(|v| v.len() >= 2) { let phase = resolve_dash_offset(&stroke.dash_offset, &clock); if let Some(dash) = skia_safe::PathEffect::dash(intervals, phase) { paint.set_path_effect(dash); } + } else if draw_start > 0.0 { + let end = if props.draw_progress >= 0.0 { + props.draw_progress.clamp(0.0, 1.0) + } else { + 1.0 + }; + let base = morphed_path + .clone() + .or_else(|| build_shape_path(shape, 0.0, 0.0, w, h, corner_radius)); + if let Some(path) = base { + trimmed_path = Some(trim_path_between(&path, draw_start, end)); + } } else if props.draw_progress >= 0.0 && props.draw_progress < 1.0 { if let Some(path) = build_shape_path(shape, 0.0, 0.0, w, h, corner_radius) { let mut measure = skia_safe::PathMeasure::new(&path, false, None); @@ -249,7 +377,12 @@ impl Painter for Shape { } } - draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint); + match trimmed_path.as_ref().or(morphed_path.as_ref()) { + Some(path) => { + canvas.draw_path(path, &paint); + } + None => draw_shape_path(canvas, shape, 0.0, 0.0, w, h, corner_radius, &paint), + }; } if let Some(text) = &self.text { @@ -347,3 +480,204 @@ fn render_shape_text( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use rustmotion_core::traits::TimingConfig; + use skia_safe::{surfaces, AlphaType, ColorType, ImageInfo}; + + const W: i32 = 200; + const H: i32 = 20; + + fn ctx_at(time: f64) -> PaintCtx { + PaintCtx { + time, + scenario_time: time, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + } + } + + fn layout() -> BoxLayout { + BoxLayout { + width: W as f32, + height: H as f32, + ..Default::default() + } + } + + fn straight_line(color: &str) -> Shape { + Shape { + shape: ShapeType::Path { + data: format!("M0 {} L{} {}", H / 2, W, H / 2), + }, + text: None, + timing: TimingConfig::default(), + style: CssStyle::default(), + timeline: vec![], + stagger: None, + fill: None, + stroke: Some(Stroke { + color: color.to_string(), + width: 4.0, + dashed: None, + dash_offset: None, + line_cap: LineCap::Butt, + line_join: LineJoin::Miter, + }), + draw_start: None, + path_morph: None, + } + } + + fn render_at(shape: &Shape, props: &AnimatedProperties, time: f64) -> Vec { + let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None); + let mut surface = surfaces::raster(&info, None, None).expect("raster surface"); + surface.canvas().clear(skia_safe::Color::BLACK); + shape.paint_content(surface.canvas(), &layout(), props, &ctx_at(time)); + let row_bytes = W as usize * 4; + let mut pixels = vec![0u8; row_bytes * H as usize]; + surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); + pixels + } + + fn render(shape: &Shape, props: &AnimatedProperties) -> Vec { + render_at(shape, props, 0.0) + } + + fn pixel(buf: &[u8], x: i32, y: i32) -> [u8; 4] { + let base = ((y * W + x) * 4) as usize; + [buf[base], buf[base + 1], buf[base + 2], buf[base + 3]] + } + + fn is_red(px: [u8; 4]) -> bool { + px[0] > 200 && px[1] < 40 && px[2] < 40 + } + + #[test] + fn draw_start_leaves_no_ink_before_it() { + let mut shape = straight_line("#FF0000"); + shape.draw_start = Some(Computed::Literal(0.5)); + let props = AnimatedProperties { + draw_progress: 1.0, + ..AnimatedProperties::default() + }; + let out = render(&shape, &props); + + for x in [5, 40, 90] { + assert!( + !is_red(pixel(&out, x, H / 2)), + "x={x} is before draw_start=0.5 on a 200px line and must have no ink" + ); + } + for x in [110, 150, 195] { + assert!( + is_red(pixel(&out, x, H / 2)), + "x={x} is after draw_start=0.5 and before draw_progress=1.0, so it must be lit" + ); + } + } + + #[test] + fn draw_start_absent_matches_the_pre_existing_draw_progress_behaviour() { + let with_zero = { + let mut shape = straight_line("#FF0000"); + shape.draw_start = Some(Computed::Literal(0.0)); + let props = AnimatedProperties { + draw_progress: 0.5, + ..AnimatedProperties::default() + }; + render(&shape, &props) + }; + let without_field = { + let shape = straight_line("#FF0000"); + let props = AnimatedProperties { + draw_progress: 0.5, + ..AnimatedProperties::default() + }; + render(&shape, &props) + }; + assert_eq!( + with_zero, without_field, + "an explicit draw_start of 0.0 must render identically to draw_start absent" + ); + } + + #[test] + fn draw_start_without_an_active_draw_progress_erases_from_the_tail() { + let mut shape = straight_line("#FF0000"); + shape.draw_start = Some(Computed::Literal(0.5)); + let props = AnimatedProperties::default(); + let out = render(&shape, &props); + + assert!( + !is_red(pixel(&out, 20, H / 2)), + "with draw_progress never animated, draw_start still trims the head of the stroke" + ); + assert!( + is_red(pixel(&out, 180, H / 2)), + "the tail (up to the implicit end of 1.0) must remain lit" + ); + } + + #[test] + fn path_morph_interpolates_between_same_structure_paths() { + let mut shape = straight_line("#FF0000"); + shape.stroke = None; + shape.fill = Some(Fill::Solid("#FF0000".to_string())); + shape.path_morph = Some(PathMorph { + keyframes: vec![ + PathMorphKeyframe { + time: 0.0, + value: "M0 0 L10 0 L10 10 L0 10 Z".to_string(), + }, + PathMorphKeyframe { + time: 1.0, + value: format!("M0 0 L{W} 0 L{W} {H} L0 {H} Z"), + }, + ], + easing: EasingType::Linear, + repeat: false, + yoyo: false, + }); + + let out = render_at(&shape, &AnimatedProperties::default(), 0.5); + + assert!( + is_red(pixel(&out, 5, H / 2)), + "the near corner must already be inside the halfway-grown rectangle" + ); + assert!( + !is_red(pixel(&out, W - 5, H / 2)), + "the far corner must not be covered yet at t=0.5 of the morph" + ); + } + + #[test] + fn path_morph_with_a_structure_mismatch_does_not_panic_and_holds_a_shape() { + let mut shape = straight_line("#FF0000"); + shape.stroke = None; + shape.fill = Some(Fill::Solid("#FF0000".to_string())); + shape.path_morph = Some(PathMorph { + keyframes: vec![ + PathMorphKeyframe { + time: 0.0, + value: "M0 0 L10 0 L10 10 Z".to_string(), + }, + PathMorphKeyframe { + time: 1.0, + value: "M0 0 L10 0 L10 10 L5 15 L0 10 Z".to_string(), + }, + ], + easing: EasingType::Linear, + repeat: false, + yoyo: false, + }); + let _ = render_at(&shape, &AnimatedProperties::default(), 0.5); + } +} diff --git a/crates/rustmotion-core/src/engine/renderer/shapes.rs b/crates/rustmotion-core/src/engine/renderer/shapes.rs index 6410b28..7ea9299 100644 --- a/crates/rustmotion-core/src/engine/renderer/shapes.rs +++ b/crates/rustmotion-core/src/engine/renderer/shapes.rs @@ -1,7 +1,102 @@ -use skia_safe::{Canvas, Paint, Rect}; +use skia_safe::{Canvas, Paint, Path, PathBuilder, PathMeasure, PathVerb, Point, Rect}; use crate::schema::ShapeType; +pub fn trim_path_between(path: &Path, start: f32, end: f32) -> Path { + let (start, end) = (start.clamp(0.0, 1.0), end.clamp(0.0, 1.0)); + let (start, end) = if start <= end { + (start, end) + } else { + (end, start) + }; + if end - start <= 0.0 { + return PathBuilder::new().detach(); + } + + let mut measure = PathMeasure::new(path, false, None); + let length = measure.length(); + if length <= 0.0 { + return PathBuilder::new().detach(); + } + + let mut builder = PathBuilder::new(); + let extracted = measure.get_segment(length * start, length * end, &mut builder, true); + if extracted { + builder.detach() + } else { + PathBuilder::new().detach() + } +} + +pub fn interpolate_path_data(from: &str, to: &str, t: f32) -> Option { + let path_from = Path::from_svg(from)?; + let path_to = Path::from_svg(to)?; + let verbs_from: Vec<(PathVerb, Vec)> = path_from + .iter() + .map(|rec| (rec.verb(), rec.points().to_vec())) + .collect(); + let verbs_to: Vec<(PathVerb, Vec)> = path_to + .iter() + .map(|rec| (rec.verb(), rec.points().to_vec())) + .collect(); + + if verbs_from.len() != verbs_to.len() { + eprintln!( + "rustmotion: path keyframe mismatch — \"{from}\" has {} command(s), \"{to}\" has \ + {}; interpolating path data needs the same command structure on every keyframe. \ + Holding the first keyframe's shape instead of snapping.", + verbs_from.len(), + verbs_to.len() + ); + return None; + } + + let t = t.clamp(0.0, 1.0); + let mut builder = PathBuilder::new(); + for ((verb_from, pts_from), (verb_to, pts_to)) in verbs_from.iter().zip(verbs_to.iter()) { + let (verb_from, verb_to) = (*verb_from, *verb_to); + if verb_from != verb_to || pts_from.len() != pts_to.len() { + eprintln!( + "rustmotion: path keyframe mismatch — \"{from}\" and \"{to}\" use a different \ + command at the same position ({verb_from:?} vs {verb_to:?}); interpolating \ + path data needs the same command structure on every keyframe. Holding the \ + first keyframe's shape instead of snapping." + ); + return None; + } + + let lerped: Vec = pts_from + .iter() + .zip(pts_to.iter()) + .map(|(p, q)| Point::new(p.x + (q.x - p.x) * t, p.y + (q.y - p.y) * t)) + .collect(); + + match (verb_from, lerped.as_slice()) { + (PathVerb::Move, [p]) => { + builder.move_to(*p); + } + (PathVerb::Line, [_, p1]) => { + builder.line_to(*p1); + } + (PathVerb::Quad, [_, c, p1]) => { + builder.quad_to(*c, *p1); + } + (PathVerb::Conic, [_, c, p1]) => { + builder.quad_to(*c, *p1); + } + (PathVerb::Cubic, [_, c1, c2, p1]) => { + builder.cubic_to(*c1, *c2, *p1); + } + (PathVerb::Close, _) => { + builder.close(); + } + _ => {} + } + } + + Some(builder.detach()) +} + pub fn build_shape_path( shape_type: &ShapeType, x: f32, diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 51a59ea..79e5bd0 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -1,11 +1,12 @@ use crate::engine::animator::ease; use crate::engine::renderer::{color4f_from_hex, paint_from_hex}; use crate::schema::{ - EasingType, IrisRing, IrisShape, PanBackground, PixelDissolveOrder, Transition, + EasingType, IrisRing, IrisShape, MaskShape, PanBackground, PixelDissolveOrder, Transition, TransitionCorner, TransitionDirection, TransitionType, ZoomBlurOrigin, }; use skia_safe::{ - surfaces, Color4f, ColorType, Image, ImageInfo, Paint, PaintStyle, PathBuilder, Rect, + surfaces, BlurStyle, Color4f, ColorType, Image, ImageInfo, MaskFilter, Matrix, Paint, + PaintStyle, PathBuilder, Rect, }; #[derive(Debug, Clone, PartialEq)] @@ -24,6 +25,13 @@ pub struct TransitionOptions { pub hold: f32, pub ring: Option, pub reverse: bool, + pub silhouette: Option, + pub from_scale: f32, + pub to_scale: f32, + pub lobes: u32, + pub wobble: f32, + pub feather: f32, + pub band_color: Option, pub duration: f64, } @@ -44,6 +52,13 @@ impl Default for TransitionOptions { hold: 0.0, ring: None, reverse: false, + silhouette: None, + from_scale: 0.0, + to_scale: 20.0, + lobes: 8, + wobble: 0.15, + feather: 0.0, + band_color: None, duration: 0.5, } } @@ -66,6 +81,13 @@ impl From<&Transition> for TransitionOptions { hold: t.hold, ring: t.ring.clone(), reverse: t.reverse, + silhouette: t.silhouette.clone(), + from_scale: t.from_scale, + to_scale: t.to_scale, + lobes: t.lobes, + wobble: t.wobble, + feather: t.feather, + band_color: t.band_color.clone(), duration: t.duration, } } @@ -96,21 +118,58 @@ pub fn apply_transition( hold, ring, reverse, + silhouette, + from_scale, + to_scale, + lobes, + wobble, + feather, + band_color, duration, } = opts.clone(); match transition_type { TransitionType::Fade => blend_fade(frame_a, frame_b, progress), - TransitionType::WipeLeft => { - wipe(frame_a, frame_b, width, height, progress, Direction::Left) - } - TransitionType::WipeRight => { - wipe(frame_a, frame_b, width, height, progress, Direction::Right) - } - TransitionType::WipeUp => wipe(frame_a, frame_b, width, height, progress, Direction::Up), - TransitionType::WipeDown => { - wipe(frame_a, frame_b, width, height, progress, Direction::Down) - } + TransitionType::WipeLeft => wipe( + frame_a, + frame_b, + width, + height, + progress, + Direction::Left, + feather, + band_color.as_deref(), + ), + TransitionType::WipeRight => wipe( + frame_a, + frame_b, + width, + height, + progress, + Direction::Right, + feather, + band_color.as_deref(), + ), + TransitionType::WipeUp => wipe( + frame_a, + frame_b, + width, + height, + progress, + Direction::Up, + feather, + band_color.as_deref(), + ), + TransitionType::WipeDown => wipe( + frame_a, + frame_b, + width, + height, + progress, + Direction::Down, + feather, + band_color.as_deref(), + ), TransitionType::ZoomIn => zoom_transition(frame_a, frame_b, width, height, progress, true), TransitionType::ZoomOut => { zoom_transition(frame_a, frame_b, width, height, progress, false) @@ -150,6 +209,32 @@ pub fn apply_transition( TransitionType::Whip => whip_transition( frame_a, frame_b, width, height, progress, strength, direction, ), + TransitionType::Mask => mask_transition( + frame_a, + frame_b, + width, + height, + progress, + silhouette.as_ref(), + origin, + from_scale, + to_scale, + feather, + band_color.as_deref(), + ), + TransitionType::Blob => blob_transition( + frame_a, + frame_b, + width, + height, + progress, + origin, + lobes, + wobble, + seed, + feather, + band_color.as_deref(), + ), TransitionType::None => { if progress < 0.5 { frame_a.to_vec() @@ -323,6 +408,15 @@ enum Direction { Down, } +fn wipe_reveal_rect(direction: &Direction, w: f32, h: f32, progress: f32) -> Rect { + match direction { + Direction::Left => Rect::from_xywh(0.0, 0.0, w * progress, h), + Direction::Right => Rect::from_xywh(w * (1.0 - progress), 0.0, w * progress, h), + Direction::Up => Rect::from_xywh(0.0, 0.0, w, h * progress), + Direction::Down => Rect::from_xywh(0.0, h * (1.0 - progress), w, h * progress), + } +} + fn wipe( frame_a: &[u8], frame_b: &[u8], @@ -330,39 +424,55 @@ fn wipe( height: u32, progress: f32, direction: Direction, + feather: f32, + band_color: Option<&str>, ) -> 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), - }; - let img_b = match frame_to_image(frame_b, width, height) { - Some(i) => i, - None => return blend_fade(frame_a, frame_b, progress), - }; + if progress <= 0.0 { + return frame_a.to_vec(); + } + if progress >= 1.0 { + return frame_b.to_vec(); + } - let canvas = surface.canvas(); - let w = width as f32; - let h = height as f32; + let (w, h) = (width as f32, height as f32); + let reveal = wipe_reveal_rect(&direction, w, h, progress); - canvas.draw_image(&img_a, (0.0, 0.0), None); + if feather <= 0.0 && band_color.is_none() { + 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), + }; + let img_b = match frame_to_image(frame_b, width, height) { + Some(i) => i, + None => return blend_fade(frame_a, frame_b, progress), + }; - let clip_rect = match direction { - Direction::Left => Rect::from_xywh(0.0, 0.0, w * progress, h), - Direction::Right => Rect::from_xywh(w * (1.0 - progress), 0.0, w * progress, h), - Direction::Up => Rect::from_xywh(0.0, 0.0, w, h * progress), - Direction::Down => Rect::from_xywh(0.0, h * (1.0 - progress), w, h * progress), - }; + let canvas = surface.canvas(); + canvas.draw_image(&img_a, (0.0, 0.0), None); + canvas.save(); + canvas.clip_rect(reveal, skia_safe::ClipOp::Intersect, true); + canvas.draw_image(&img_b, (0.0, 0.0), None); + canvas.restore(); - canvas.save(); - canvas.clip_rect(clip_rect, skia_safe::ClipOp::Intersect, true); - canvas.draw_image(&img_b, (0.0, 0.0), None); - canvas.restore(); + return surface_to_pixels(surface, width, height); + } - surface_to_pixels(surface, width, height) + let mut builder = PathBuilder::new(); + builder.add_rect(reveal, None, None); + let path = builder.detach(); + composite_through_mask( + frame_a, + frame_b, + width, + height, + &path, + feather.max(0.0), + band_color, + ) } fn create_skia_surface(width: u32, height: u32) -> Option { @@ -714,6 +824,261 @@ fn iris_transition( blend_fade(&filled, frame_b, t) } +fn mask_shape_to_local_path(shape: &MaskShape) -> Option { + match shape { + MaskShape::Polygon { points } => { + if points.len() < 3 { + return None; + } + let mut builder = PathBuilder::new(); + for (i, (x, y)) in points.iter().enumerate() { + if i == 0 { + builder.move_to((*x, *y)); + } else { + builder.line_to((*x, *y)); + } + } + builder.close(); + Some(builder.detach()) + } + MaskShape::Path { d } => skia_safe::Path::from_svg(d), + } +} + +fn scaled_mask_path(local: &skia_safe::Path, scale: f32, origin: (f32, f32)) -> skia_safe::Path { + let bounds = *local.bounds(); + let cx = (bounds.left + bounds.right) / 2.0; + let cy = (bounds.top + bounds.bottom) / 2.0; + let safe_scale = scale.max(0.0001); + let mut matrix = Matrix::default(); + matrix.pre_translate((origin.0, origin.1)); + matrix.pre_scale((safe_scale, safe_scale), None); + matrix.pre_translate((-cx, -cy)); + local.with_transform(&matrix) +} + +fn mask_alpha_buffer( + path: &skia_safe::Path, + width: u32, + height: u32, + feather: f32, +) -> Option> { + let info = ImageInfo::new( + (width as i32, height as i32), + ColorType::Alpha8, + skia_safe::AlphaType::Premul, + None, + ); + let mut surface = surfaces::raster(&info, None, None)?; + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Fill); + paint.set_alpha(255); + let sigma = (feather.max(0.0) / 3.0).max(0.05); + if let Some(mask_filter) = MaskFilter::blur(BlurStyle::Normal, sigma, None) { + paint.set_mask_filter(mask_filter); + } + surface.canvas().draw_path(path, &paint); + let row_bytes = width as usize; + let mut buf = vec![0u8; row_bytes * height as usize]; + surface.read_pixels(&info, &mut buf, row_bytes, (0, 0)); + Some(buf) +} + +fn hard_mask_composite( + outer: &[u8], + inner: &[u8], + width: u32, + height: u32, + path: &skia_safe::Path, +) -> Vec { + let mut surface = match create_skia_surface(width, height) { + Some(s) => s, + None => return outer.to_vec(), + }; + let (Some(img_outer), Some(img_inner)) = ( + frame_to_image(outer, width, height), + frame_to_image(inner, width, height), + ) else { + return outer.to_vec(); + }; + + let canvas = surface.canvas(); + canvas.draw_image(&img_outer, (0.0, 0.0), None); + canvas.save(); + canvas.clip_path(path, skia_safe::ClipOp::Intersect, true); + canvas.draw_image(&img_inner, (0.0, 0.0), None); + canvas.restore(); + + surface_to_pixels(surface, width, height) +} + +fn composite_through_mask( + outer: &[u8], + inner: &[u8], + width: u32, + height: u32, + path: &skia_safe::Path, + feather: f32, + band_color: Option<&str>, +) -> Vec { + if feather <= 0.0 { + return hard_mask_composite(outer, inner, width, height, path); + } + let Some(alpha) = mask_alpha_buffer(path, width, height, feather) else { + return hard_mask_composite(outer, inner, width, height, path); + }; + + let band = band_color.map(color4f_from_hex); + let pixel_count = (width * height) as usize; + let mut out = vec![0u8; pixel_count * 4]; + for (i, &alpha_byte) in alpha.iter().enumerate().take(pixel_count) { + let a = alpha_byte as f32 / 255.0; + let base = i * 4; + let mut rgb = [0f32; 3]; + for (c, slot) in rgb.iter_mut().enumerate() { + let o = outer[base + c] as f32; + let n = inner[base + c] as f32; + *slot = o * (1.0 - a) + n * a; + } + if let Some(band) = &band { + let weight = 4.0 * a * (1.0 - a); + let band_rgb = [band.r * 255.0, band.g * 255.0, band.b * 255.0]; + for (slot, band_channel) in rgb.iter_mut().zip(band_rgb) { + *slot = *slot * (1.0 - weight) + band_channel * weight; + } + } + for (c, value) in rgb.into_iter().enumerate() { + out[base + c] = value.round().clamp(0.0, 255.0) as u8; + } + out[base + 3] = outer[base + 3].max(inner[base + 3]); + } + out +} + +#[allow(clippy::too_many_arguments)] +fn mask_transition( + frame_a: &[u8], + frame_b: &[u8], + width: u32, + height: u32, + progress: f32, + silhouette: Option<&MaskShape>, + origin: Option, + from_scale: f32, + to_scale: f32, + feather: f32, + band_color: Option<&str>, +) -> Vec { + if progress <= 0.0 { + return frame_a.to_vec(); + } + if progress >= 1.0 { + return frame_b.to_vec(); + } + let Some(shape) = silhouette else { + eprintln!( + "rustmotion: transition type \"mask\" needs a `silhouette` (polygon or path); \ + falling back to a plain fade" + ); + return blend_fade(frame_a, frame_b, progress); + }; + let Some(local) = mask_shape_to_local_path(shape) else { + eprintln!( + "rustmotion: transition \"mask\" `silhouette` did not resolve to a path (a \ + `polygon` needs at least 3 points, a `path`'s `d` must be valid SVG path data); \ + falling back to a plain fade" + ); + return blend_fade(frame_a, frame_b, progress); + }; + + let (w, h) = (width as f32, height as f32); + let origin_px = match origin { + Some(o) => (o.x, o.y), + None => (w / 2.0, h / 2.0), + }; + let scale = from_scale.max(0.0) + (to_scale.max(0.0) - from_scale.max(0.0)) * progress; + let path = scaled_mask_path(&local, scale, origin_px); + composite_through_mask( + frame_a, + frame_b, + width, + height, + &path, + feather.max(0.0), + band_color, + ) +} + +fn blob_local_path(lobes: u32, wobble: f32, seed: u32) -> skia_safe::Path { + let n = lobes.max(3); + let wobble = wobble.clamp(0.0, 0.95); + let points: Vec<(f32, f32)> = (0..n) + .map(|i| { + let t = i as f32 / n as f32; + let angle = t * std::f32::consts::TAU; + let noise = cell_hash01(i as i32, 0, seed) * 2.0 - 1.0; + let radius = 1.0 + wobble * noise; + (angle.cos() * radius, angle.sin() * radius) + }) + .collect(); + + let midpoint = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + let n = n as usize; + let mut builder = PathBuilder::new(); + builder.move_to(midpoint(points[n - 1], points[0])); + for i in 0..n { + let next = points[(i + 1) % n]; + builder.quad_to(points[i], midpoint(points[i], next)); + } + builder.close(); + builder.detach() +} + +#[allow(clippy::too_many_arguments)] +fn blob_transition( + frame_a: &[u8], + frame_b: &[u8], + width: u32, + height: u32, + progress: f32, + origin: Option, + lobes: u32, + wobble: f32, + seed: u32, + feather: f32, + band_color: Option<&str>, +) -> Vec { + if progress <= 0.0 { + return frame_a.to_vec(); + } + if progress >= 1.0 { + return frame_b.to_vec(); + } + + let (w, h) = (width as f32, height as f32); + let origin_px = match origin { + Some(o) => (o.x, o.y), + None => (w / 2.0, h / 2.0), + }; + let coverage_radius = iris_max_radius(origin_px, w, h, IrisShape::Circle, 1.0); + let wobble = wobble.clamp(0.0, 0.95); + let shrunk_lobe_safety = (1.0 - wobble).max(0.05); + let max_radius = coverage_radius / shrunk_lobe_safety; + let local = blob_local_path(lobes, wobble, seed); + let scale = max_radius * progress; + let path = scaled_mask_path(&local, scale, origin_px); + composite_through_mask( + frame_a, + frame_b, + width, + height, + &path, + feather.max(0.0), + band_color, + ) +} + fn slide_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 b119924..c3a5f68 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -1320,6 +1320,35 @@ fn default_iris_aspect() -> f32 { 1.0 } +/// The arbitrary silhouette a `mask` transition scales up from `origin`, +/// authored in the shape's own local coordinates (whatever range its points +/// or path data use — a 100x100 star, a 24x24 icon glyph, anything). The +/// silhouette's own bounding-box centre is what tracks `origin`, not a fixed +/// point like `(0, 0)`, so a lopsided glyph still grows from its visual +/// middle. Reuses the same `kind` vocabulary as `style.clip-path` +/// (`polygon`, `path`) rather than inventing a second grammar for the same +/// idea. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum MaskShape { + /// A closed straight-edge outline, in local units. + Polygon { points: Vec<(f32, f32)> }, + /// SVG path data, in local units. + Path { d: String }, +} + +fn default_mask_to_scale() -> f32 { + 20.0 +} + +fn default_blob_lobes() -> u32 { + 8 +} + +fn default_blob_wobble() -> f32 { + 0.15 +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Transition { @@ -1331,8 +1360,10 @@ pub struct Transition { /// Cell edge in px for `pixel_dissolve`. Ignored by every other type. #[serde(default = "default_transition_cell")] pub cell: f32, - /// `pixel_dissolve` only: stable scatter selector. Two transitions with the - /// same seed dissolve in the same order. + /// `pixel_dissolve` and `blob` only: `pixel_dissolve`'s stable scatter + /// selector, or the stable lobe-wobble selector `blob` grows its + /// silhouette from. Two transitions with the same seed (and, for `blob`, + /// the same `lobes`/`wobble`) reproduce the same shape. #[serde(default = "default_transition_seed")] pub seed: u32, /// `pixel_dissolve` only: which cells turn first. @@ -1354,9 +1385,11 @@ pub struct Transition { /// slide with no smear. Ignored by every other transition type. #[serde(default = "default_transition_strength")] pub strength: f32, - /// `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. + /// `zoom_blur`, `iris`, `mask` and `blob` only: for `zoom_blur`, the + /// centre the streaks radiate from; for the other three, the mask's + /// centre — in `mask`'s case, the point its silhouette's own + /// bounding-box centre grows from. Ignored by every other transition + /// type. Frame pixels, like `zoom_blur`'s, not a `0..1` fraction. #[serde(default)] pub origin: Option, /// `iris` only: the mask's silhouette. Ignored by every other type. @@ -1387,6 +1420,52 @@ pub struct Transition { /// transition type. #[serde(default)] pub reverse: bool, + /// `mask` only: the arbitrary silhouette it scales up from `origin` — + /// `polygon` or `path`, the same `kind` vocabulary as `style.clip-path`. + /// Required for `mask`; ignored by every other type (`blob` grows its + /// own procedural silhouette instead, from `lobes`/`wobble`/`seed`). + #[serde(default)] + pub silhouette: Option, + /// `mask` only: `silhouette`'s scale factor at `progress: 0`, relative to + /// its own bounding-box size. `0` is a true point; small positive values + /// (the default) start it as a barely-visible speck rather than exactly + /// nothing, which is fine since `progress <= 0.0` always short-circuits + /// to the untouched outgoing frame regardless of this value. Ignored by + /// every other transition type. + #[serde(default)] + pub from_scale: f32, + /// `mask` only: `silhouette`'s scale factor at `progress: 1`. Large + /// enough to guarantee full-frame coverage is the author's + /// responsibility — unlike `iris`, an arbitrary silhouette's covering + /// radius cannot be solved for automatically. Ignored by every other + /// transition type. + #[serde(default = "default_mask_to_scale")] + pub to_scale: f32, + /// `blob` only: how many lobes its procedural organic silhouette grows. + /// Ignored by every other transition type. + #[serde(default = "default_blob_lobes")] + pub lobes: u32, + /// `blob` only: how far each lobe's radius wanders from a perfect circle, + /// as a fraction of it — `0` degenerates to a circle (a slow `iris` by + /// another name), values approaching `1` pinch some lobes almost to the + /// centre. Ignored by every other transition type. + #[serde(default = "default_blob_wobble")] + pub wobble: f32, + /// `wipe_*`, `mask` and `blob` only: width, in px, of the soft gradient + /// edge instead of a hard cut. `0` (the default) is a hard edge — the + /// wipe's clip-rect boundary, or the mask/blob's clip-path boundary, with + /// no blur pass at all. Ignored by every other transition type. + #[serde(default)] + pub feather: f32, + /// `wipe_*`, `mask` and `blob` only, and only visible when `feather` is + /// greater than `0`: a colour that tints the feathered edge itself, + /// peaking exactly on the boundary and fading to nothing at both sides of + /// the feather band — a coloured front that sweeps across ahead of the + /// incoming scene. Absent leaves the feather a plain alpha blend. Ignored + /// by every other transition type, and by these three when `feather` is + /// `0` (a hard edge has no band to tint). + #[serde(default)] + pub band_color: Option, #[serde(default = "default_transition_duration")] pub duration: f64, #[serde(default = "default_transition_easing")] @@ -1455,6 +1534,21 @@ pub enum TransitionType { /// collapses it to a plain `slide`. Zero at both ends of the /// transition, so no streak bleeds into the next scene. Whip, + /// `iris` generalised to any silhouette: `silhouette` (a `polygon` or a + /// `path`, the same grammar as `style.clip-path`) scales up about its own + /// bounding-box centre, from `from_scale` to `to_scale`, centred on + /// `origin`. `feather` and `band_color` soften and tint the moving edge. + /// Byte-identical to the outgoing frame at `progress: 0` and to the + /// incoming one at `progress: 1`, by construction rather than by tuning + /// `from_scale`/`to_scale` precisely. + Mask, + /// An organic metaball silhouette — `lobes` control points at a radius + /// wobbling by `wobble` around a circle, joined into a smooth closed + /// blob — growing from `origin` exactly like `mask`, but with a covering + /// radius solved for automatically the way `iris`'s is, instead of an + /// author-supplied `to_scale`. `seed` picks which wobble the lobes get. + /// `feather` and `band_color` behave as they do on `mask`. + Blob, None, } diff --git a/crates/rustmotion-core/tests/mask_blob_transitions.rs b/crates/rustmotion-core/tests/mask_blob_transitions.rs new file mode 100644 index 0000000..09a2183 --- /dev/null +++ b/crates/rustmotion-core/tests/mask_blob_transitions.rs @@ -0,0 +1,368 @@ +use rustmotion_core::engine::transition::{apply_transition, TransitionOptions}; +use rustmotion_core::schema::{MaskShape, TransitionType, ZoomBlurOrigin}; + +const W: u32 = 100; +const H: u32 = 100; + +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 pixel(buf: &[u8], x: u32, y: u32) -> [u8; 4] { + let base = ((y * W + x) * 4) as usize; + [buf[base], buf[base + 1], buf[base + 2], buf[base + 3]] +} + +fn square_silhouette() -> MaskShape { + MaskShape::Polygon { + points: vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], + } +} + +fn mask_opts(from_scale: f32, to_scale: f32) -> TransitionOptions { + TransitionOptions { + silhouette: Some(square_silhouette()), + origin: Some(ZoomBlurOrigin { x: 50.0, y: 50.0 }), + from_scale, + to_scale, + ..TransitionOptions::default() + } +} + +fn composite_mask(progress: f64, o: &TransitionOptions) -> Vec { + let (a, b) = frames(); + apply_transition(&a, &b, W, H, progress, &TransitionType::Mask, o) +} + +fn composite_blob(progress: f64, o: &TransitionOptions) -> Vec { + let (a, b) = frames(); + apply_transition(&a, &b, W, H, progress, &TransitionType::Blob, o) +} + +fn composite_wipe(progress: f64, wipe: &TransitionType, o: &TransitionOptions) -> Vec { + let (a, b) = frames(); + apply_transition(&a, &b, W, H, progress, wipe, o) +} + +#[test] +fn mask_progress_zero_is_byte_identical_to_frame_a_even_with_a_visible_from_scale() { + let (a, _) = frames(); + let o = mask_opts(0.2, 40.0); + assert_eq!( + composite_mask(0.0, &o), + a, + "progress 0.0 must be the untouched outgoing frame, even though from_scale is nonzero \ + and would otherwise leave a visible speck of the incoming scene" + ); +} + +#[test] +fn mask_progress_one_is_byte_identical_to_frame_b() { + let (_, b) = frames(); + let o = mask_opts(0.2, 40.0); + assert_eq!( + composite_mask(1.0, &o), + b, + "progress 1.0 must be the untouched incoming frame" + ); +} + +#[test] +fn mask_reveals_only_the_area_the_silhouette_covers_mid_growth() { + let o = mask_opts(0.0, 10.0); + let out = composite_mask(0.5, &o); + assert_eq!( + pixel(&out, 50, 50), + [40, 40, 40, 255], + "frame centre, well inside the grown square, must already show the incoming scene" + ); + assert_eq!( + pixel(&out, 5, 5), + [200, 200, 200, 255], + "a far corner, outside the grown square, must still show the outgoing scene" + ); +} + +#[test] +fn mask_origin_relocates_which_area_is_revealed() { + let centred = composite_mask( + 0.4, + &TransitionOptions { + silhouette: Some(square_silhouette()), + origin: Some(ZoomBlurOrigin { x: 50.0, y: 50.0 }), + from_scale: 0.0, + to_scale: 10.0, + ..TransitionOptions::default() + }, + ); + let corner_origin = composite_mask( + 0.4, + &TransitionOptions { + silhouette: Some(square_silhouette()), + origin: Some(ZoomBlurOrigin { x: 5.0, y: 5.0 }), + from_scale: 0.0, + to_scale: 10.0, + ..TransitionOptions::default() + }, + ); + assert_ne!( + centred, corner_origin, + "moving `origin` must change which area the silhouette grows around" + ); +} + +#[test] +fn mask_without_a_silhouette_falls_back_to_a_plain_fade_instead_of_panicking() { + let o = TransitionOptions { + silhouette: None, + ..TransitionOptions::default() + }; + let out = composite_mask(0.5, &o); + let px = pixel(&out, 50, 50); + assert_eq!( + px, + [120, 120, 120, 255], + "a `mask` transition with no `silhouette` must fall back to a fade instead of leaving \ + the frame untouched or panicking" + ); +} + +#[test] +fn mask_with_a_degenerate_polygon_falls_back_to_a_plain_fade() { + let o = TransitionOptions { + silhouette: Some(MaskShape::Polygon { + points: vec![(0.0, 0.0), (1.0, 1.0)], + }), + ..TransitionOptions::default() + }; + let out = composite_mask(0.5, &o); + assert_eq!( + pixel(&out, 50, 50), + [120, 120, 120, 255], + "fewer than 3 points cannot describe a silhouette; it must fall back loudly to a fade" + ); +} + +#[test] +fn mask_reuses_the_clip_path_path_grammar() { + let star_d = "M50 0 L61 35 L98 35 L68 57 L79 91 L50 70 L21 91 L32 57 L2 35 L39 35 Z"; + let o = TransitionOptions { + silhouette: Some(MaskShape::Path { + d: star_d.to_string(), + }), + origin: Some(ZoomBlurOrigin { x: 50.0, y: 50.0 }), + from_scale: 0.0, + to_scale: 3.0, + ..TransitionOptions::default() + }; + let out = composite_mask(0.6, &o); + assert_eq!( + pixel(&out, 50, 45), + [40, 40, 40, 255], + "the star's own centre must be inside its own silhouette once it has grown" + ); +} + +#[test] +fn blob_progress_zero_is_byte_identical_to_frame_a_at_strong_settings() { + let (a, _) = frames(); + let o = TransitionOptions { + lobes: 12, + wobble: 0.6, + seed: 99, + ..TransitionOptions::default() + }; + assert_eq!( + composite_blob(0.0, &o), + a, + "progress 0.0 must be the untouched outgoing frame regardless of lobes/wobble" + ); +} + +#[test] +fn blob_progress_one_is_byte_identical_to_frame_b_at_strong_settings() { + let (_, b) = frames(); + let o = TransitionOptions { + lobes: 12, + wobble: 0.6, + seed: 99, + ..TransitionOptions::default() + }; + assert_eq!( + composite_blob(1.0, &o), + b, + "progress 1.0 must be the untouched incoming frame regardless of lobes/wobble" + ); +} + +#[test] +fn blob_covers_the_whole_frame_by_the_end_of_growth_from_any_origin() { + 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, + }), + ] { + let o = TransitionOptions { + origin, + lobes: 7, + wobble: 0.2, + seed: 3, + ..TransitionOptions::default() + }; + assert_eq!( + composite_blob(0.999, &o), + b, + "just short of the end the blob's automatically solved covering radius must already \ + reveal the whole frame, whatever origin it grows from" + ); + } +} + +#[test] +fn blob_same_seed_reproduces_the_same_silhouette() { + let o = TransitionOptions { + lobes: 9, + wobble: 0.3, + seed: 42, + ..TransitionOptions::default() + }; + assert_eq!( + composite_blob(0.3, &o), + composite_blob(0.3, &o), + "the same seed, lobes and wobble must grow an identical silhouette" + ); +} + +#[test] +fn blob_a_different_seed_grows_a_different_silhouette() { + let a = TransitionOptions { + lobes: 9, + wobble: 0.3, + seed: 42, + ..TransitionOptions::default() + }; + let b = TransitionOptions { + lobes: 9, + wobble: 0.3, + seed: 7, + ..TransitionOptions::default() + }; + assert_ne!( + composite_blob(0.3, &a), + composite_blob(0.3, &b), + "a different seed must wobble the lobes differently" + ); +} + +#[test] +fn feathered_wipe_progress_zero_is_byte_identical_to_frame_a() { + let (a, _) = frames(); + let o = TransitionOptions { + feather: 40.0, + band_color: Some("#00FF00".to_string()), + ..TransitionOptions::default() + }; + assert_eq!( + composite_wipe(0.0, &TransitionType::WipeLeft, &o), + a, + "a large feather and a band colour must still be exactly zero at progress 0.0" + ); +} + +#[test] +fn feathered_wipe_progress_one_is_byte_identical_to_frame_b() { + let (_, b) = frames(); + let o = TransitionOptions { + feather: 40.0, + band_color: Some("#00FF00".to_string()), + ..TransitionOptions::default() + }; + assert_eq!( + composite_wipe(1.0, &TransitionType::WipeLeft, &o), + b, + "a large feather and a band colour must still be exactly complete at progress 1.0" + ); +} + +#[test] +fn zero_feather_wipe_has_no_partially_blended_pixels() { + let o = TransitionOptions { + feather: 0.0, + ..TransitionOptions::default() + }; + let out = composite_wipe(0.5, &TransitionType::WipeLeft, &o); + for px in out.as_chunks::<4>().0 { + assert!( + *px == [200, 200, 200, 255] || *px == [40, 40, 40, 255], + "with feather 0 every pixel must be exactly one of the two source colours, got {px:?}" + ); + } +} + +#[test] +fn a_positive_feather_creates_a_blended_band_at_the_edge() { + let o = TransitionOptions { + feather: 30.0, + ..TransitionOptions::default() + }; + let out = composite_wipe(0.5, &TransitionType::WipeLeft, &o); + let has_blend = out.as_chunks::<4>().0.iter().any(|px| { + px[0] != 200 && px[0] != 40 // neither source grey level + }); + assert!( + has_blend, + "feather must create pixels that are neither source frame's exact colour" + ); +} + +#[test] +fn band_color_tints_the_feathered_edge_with_a_hue_absent_from_both_scenes() { + let o = TransitionOptions { + feather: 30.0, + band_color: Some("#00FF00".to_string()), + ..TransitionOptions::default() + }; + let out = composite_wipe(0.5, &TransitionType::WipeLeft, &o); + let has_green_fringe = out + .as_chunks::<4>() + .0 + .iter() + .any(|px| px[1] > px[0] + 30 && px[1] > px[2] + 30); + assert!( + has_green_fringe, + "a band colour absent from both grey scenes must still show up somewhere along the \ + feathered edge" + ); +} + +#[test] +fn band_color_is_a_no_op_without_a_feather() { + let with_band = composite_wipe( + 0.5, + &TransitionType::WipeLeft, + &TransitionOptions { + feather: 0.0, + band_color: Some("#00FF00".to_string()), + ..TransitionOptions::default() + }, + ); + let without_band = composite_wipe( + 0.5, + &TransitionType::WipeLeft, + &TransitionOptions { + feather: 0.0, + band_color: None, + ..TransitionOptions::default() + }, + ); + assert_eq!( + with_band, without_band, + "a hard edge (feather 0) has no band to tint, so band_color must be ignored" + ); +} diff --git a/crates/rustmotion-core/tests/path_geometry.rs b/crates/rustmotion-core/tests/path_geometry.rs new file mode 100644 index 0000000..f71af15 --- /dev/null +++ b/crates/rustmotion-core/tests/path_geometry.rs @@ -0,0 +1,141 @@ +use rustmotion_core::engine::renderer::{interpolate_path_data, trim_path_between}; +use skia_safe::Path; + +fn horizontal_line(len: f32) -> Path { + Path::from_svg(format!("M0 0 L{len} 0")).unwrap() +} + +#[test] +fn trim_at_zero_and_progress_at_one_keeps_the_whole_path() { + let path = horizontal_line(100.0); + let trimmed = trim_path_between(&path, 0.0, 1.0); + let mut measure = skia_safe::PathMeasure::new(&trimmed, false, None); + assert!( + (measure.length() - 100.0).abs() < 0.5, + "start=0, end=1 must keep the full path, got length {}", + measure.length() + ); +} + +#[test] +fn trim_start_at_half_removes_the_first_half() { + let path = horizontal_line(100.0); + let trimmed = trim_path_between(&path, 0.5, 1.0); + let mut measure = skia_safe::PathMeasure::new(&trimmed, false, None); + assert!( + (measure.length() - 50.0).abs() < 0.5, + "start=0.5 must leave half the path visible, got length {}", + measure.length() + ); + + let bounds = trimmed.bounds(); + assert!( + bounds.left >= 49.0, + "the first half of the path must have no ink; visible bounds start at {}", + bounds.left + ); +} + +#[test] +fn trim_end_at_half_removes_the_second_half() { + let path = horizontal_line(100.0); + let trimmed = trim_path_between(&path, 0.0, 0.5); + let bounds = trimmed.bounds(); + assert!( + bounds.right <= 51.0, + "the second half of the path must have no ink; visible bounds end at {}", + bounds.right + ); +} + +#[test] +fn trim_start_after_end_yields_nothing_instead_of_swapping_silently() { + let path = horizontal_line(100.0); + let inverted = trim_path_between(&path, 0.8, 0.8); + let mut measure = skia_safe::PathMeasure::new(&inverted, false, None); + assert!( + measure.length() < 0.5, + "a zero-width window must paint nothing, got length {}", + measure.length() + ); +} + +#[test] +fn trim_reversed_start_and_end_is_treated_as_the_same_window() { + let path = horizontal_line(100.0); + let forward = trim_path_between(&path, 0.2, 0.6); + let backward = trim_path_between(&path, 0.6, 0.2); + let mut mf = skia_safe::PathMeasure::new(&forward, false, None); + let mut mb = skia_safe::PathMeasure::new(&backward, false, None); + assert!( + (mf.length() - mb.length()).abs() < 0.5, + "trim_path_between(0.2, 0.6) and trim_path_between(0.6, 0.2) must describe the same \ + window, got {} and {}", + mf.length(), + mb.length() + ); +} + +#[test] +fn interpolate_matching_structure_paths_blends_the_points() { + let from = "M0 0 L10 0 L10 10 Z"; + let to = "M0 0 L20 0 L20 20 Z"; + let mid = interpolate_path_data(from, to, 0.5).expect("same command structure must succeed"); + let bounds = mid.bounds(); + assert!( + (bounds.right - 15.0).abs() < 0.01, + "halfway between a 10-wide and a 20-wide triangle must be 15 wide, got {}", + bounds.right + ); + assert!( + (bounds.bottom - 15.0).abs() < 0.01, + "halfway between a 10-tall and a 20-tall triangle must be 15 tall, got {}", + bounds.bottom + ); +} + +#[test] +fn interpolate_at_t_zero_matches_the_first_keyframe_exactly() { + let from = "M0 0 L10 0 L10 10 Z"; + let to = "M0 0 L40 0 L40 40 Z"; + let start = interpolate_path_data(from, to, 0.0).unwrap(); + let reference = Path::from_svg(from).unwrap(); + assert_eq!(*start.bounds(), *reference.bounds()); +} + +#[test] +fn interpolate_at_t_one_matches_the_second_keyframe_exactly() { + let from = "M0 0 L10 0 L10 10 Z"; + let to = "M0 0 L40 0 L40 40 Z"; + let end = interpolate_path_data(from, to, 1.0).unwrap(); + let reference = Path::from_svg(to).unwrap(); + assert_eq!(*end.bounds(), *reference.bounds()); +} + +#[test] +fn interpolate_mismatched_command_counts_is_reported_and_returns_none() { + let triangle = "M0 0 L10 0 L10 10 Z"; + let pentagon = "M0 0 L10 0 L10 10 L5 15 L0 10 Z"; + assert!( + interpolate_path_data(triangle, pentagon, 0.5).is_none(), + "a triangle and a pentagon do not share a command structure; interpolation must refuse \ + rather than snap or panic" + ); +} + +#[test] +fn interpolate_mismatched_command_kind_at_the_same_position_is_reported_and_returns_none() { + let with_a_line = "M0 0 L10 0 L10 10 Z"; + let with_a_curve = "M0 0 Q5 5 10 0 L10 10 Z"; + assert!( + interpolate_path_data(with_a_line, with_a_curve, 0.5).is_none(), + "a line and a quadratic curve at the same position in the command list must refuse to \ + interpolate rather than silently reinterpreting the control point" + ); +} + +#[test] +fn interpolate_unparseable_svg_returns_none_instead_of_panicking() { + assert!(interpolate_path_data("not a path", "M0 0 L10 0", 0.5).is_none()); + assert!(interpolate_path_data("M0 0 L10 0", "not a path", 0.5).is_none()); +} diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 285ba2f..f8e3224 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -243,6 +243,9 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/char-animation-rich-and-gradient-text.md](rules/char-animation-rich-and-gradient-text.md) - `char_*` presets on `rich_text` and `gradient_text`: stagger across span boundaries, per-span `ink_from`, and why `ink_from` is inert on a gradient - [rules/text-morph.md](rules/text-morph.md) - `text.morph`: matched letters travel, unmatched ones fade or scramble — how it differs from `text.states` + `swap` - [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/mask-transition.md](rules/mask-transition.md) - `mask` and `blob`: reveal through any silhouette, and an organic wobbling edge whose covering radius solves itself +- [rules/feathered-wipes.md](rules/feathered-wipes.md) - `feather` and `band_color`, shared by every `wipe_*` plus `mask` and `blob` +- [rules/shape-draw-start-and-path-morph.md](rules/shape-draw-start-and-path-morph.md) - `shape.draw_start` and `shape.path_morph` — and why they are component fields, not `style.animation` keyframe properties - [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/draw-progress-stroke.md](rules/draw-progress-stroke.md) - `draw_progress` at 0 paints nothing, and an `svg` draw-on matches the finished mark's stroke width, cap and join diff --git a/crates/rustmotion/skills/rules/feathered-wipes.md b/crates/rustmotion/skills/rules/feathered-wipes.md new file mode 100644 index 0000000..75d453d --- /dev/null +++ b/crates/rustmotion/skills/rules/feathered-wipes.md @@ -0,0 +1,36 @@ +# Rule: `feather` et `band_color` — un bord doux, éventuellement teinté + +`feather` et `band_color` sont deux champs partagés par les `wipe_*` (`wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`), `mask` et `blob` (voir [rules/mask-transition.md](mask-transition.md)). Ignorés par toute autre transition. + +```json +{ + "transition": { + "type": "wipe_left", + "duration": 0.5, + "feather": 320, + "band_color": "#FF8CC6" + } +} +``` + +## `feather` : la largeur du dégradé, en px + +`0` (le défaut) est un bord dur — exactement le comportement d'avant ce champ, pas d'approximation, pas de passe de flou du tout. Une valeur positive adoucit la limite mobile (le bord du wipe, ou le contour de la silhouette pour `mask`/`blob`) sur cette largeur en pixels du cadre final, via un `MaskFilter::blur` gaussien appliqué à un masque alpha peint puis lu en niveaux de gris — pas un dégradé linéaire à la main, ce qui suit naturellement la courbure d'une silhouette `mask`/`blob` aussi bien que le bord droit d'un wipe. + +## `band_color` : un front coloré qui balaie le bord + +Sans `band_color`, le dégradé mélange simplement les deux scènes. Avec, une teinte s'ajoute **au pic exact du bord**, en s'estompant vers les deux côtés de la bande de `feather` — un front coloré qui traverse l'écran avant que la scène entrante ne soit pleinement visible. Le poids de la teinte suit `4·a·(1-a)` où `a` est l'alpha du masque flouté : nul quand `a` vaut `0` ou `1` (loin du bord), maximal pile à `a = 0.5` (le bord lui-même) — une parabole, pas une bande à largeur fixe à régler séparément de `feather`. + +`band_color` est un **no-op sans `feather`** : un bord dur n'a pas de bande à teinter. Le mettre avec `feather: 0` ne change rien à l'image. + +## Zéro aux deux bouts, même avec un `feather` large + +Toutes les transitions qui acceptent `feather` commencent par le même court-circuit que `mask`/`blob`/`zoom_blur`/`whip` : `progress <= 0.0` rend la frame sortante **brute**, `progress >= 1.0` la frame entrante **brute**, avant toute passe de flou. Sans ce court-circuit, un `feather` large ferait déborder la bande floutée au-delà du bord du cadre au tout début ou à la toute fin de la transition, laissant fuir un peu de la scène adjacente — précisément le défaut que l'issue d'origine proscrit pour `zoom_blur`/`whip`, et qui s'appliquerait tout autant ici sans le même court-circuit. + +## Comment c'est construit + +`feather <= 0.0` (et pas de `band_color`) garde le chemin rapide historique : un simple `clip_rect`/`clip_path` sur les images déjà peintes, sans passe de flou. Dès que l'un des deux est actif, le bord (rectangle de révélation pour un wipe, silhouette mise à l'échelle pour `mask`/`blob`) est peint dans une surface `Alpha8` avec un `MaskFilter::blur`, relu comme un masque de mélange pixel par pixel entre les deux frames — le même mécanisme, `composite_through_mask`, sert les trois familles de transitions. + +## Piège : un `feather` très supérieur aux dimensions du cadre delaie visuellement le bord dur + +Le sigma du flou gaussien est dérivé de `feather` (`feather / 3`, à peu près la largeur perçue du dégradé) — un `feather` de plusieurs milliers de pixels sur un cadre HD produit un dégradé qui occupe tout l'écran en permanence, jamais un vrai bord net. Ce n'est pas un bug : `feather` est une largeur de bord, pas un rayon de flou d'ambiance — pour un fondu pleine trame, `fade` reste le bon outil. diff --git a/crates/rustmotion/skills/rules/mask-transition.md b/crates/rustmotion/skills/rules/mask-transition.md new file mode 100644 index 0000000..e37f820 --- /dev/null +++ b/crates/rustmotion/skills/rules/mask-transition.md @@ -0,0 +1,70 @@ +# Rule: `mask` et `blob` — `iris` généralisé à une silhouette arbitraire + +`mask` et `blob` sont des `transition` (au même titre que `iris`, `slide`, `chromatic_wipe`…) : comme toute transition d'une vue `slide`, elles compositent 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`. + +`iris` grandit un cercle (ou un stade) depuis `origin`. `mask` fait la même chose avec **n'importe quelle silhouette** — un logo, un blob organique, une forme chanfreinée — et `blob` grandit une silhouette organique procédurale sans avoir à en fournir les points. + +## `mask` : une silhouette au choix + +```json +{ + "transition": { + "type": "mask", + "duration": 0.6, + "easing": "ease_in_cubic", + "silhouette": { + "kind": "path", + "d": "M50 0 L61 35 L98 35 L68 57 L79 91 L50 70 L21 91 L32 57 L2 35 L39 35 Z" + }, + "origin": { "x": 960, "y": 540 }, + "from_scale": 0.05, + "to_scale": 14, + "feather": 2 + } +} +``` + +`silhouette` reprend le même vocabulaire `kind` que `style.clip-path` (voir [rules/clip-path.md](clip-path.md)) : `polygon` (`points: [[x, y], …]`) ou `path` (`d`, données SVG). Les deux sont exprimés dans les unités **propres à la silhouette** — celles de l'exemple ci-dessus décrivent une étoile dans une boîte 0-100, mais rien n'impose cette échelle : un `path` copié depuis une icône 24×24 fonctionne pareil. + +## Champs + +| Champ | Rôle | Défaut | +|---|---|---| +| `silhouette` | `mask` uniquement : la forme, `polygon` ou `path`. Requis pour `mask` — absente ou dégénérée (moins de 3 points, `d` non parsable), la transition retombe sur un `fade` et l'écrit sur stderr plutôt que de planter. | absent | +| `origin` | `mask`/`blob` : le point (pixels du cadre, **pas** une fraction `0..1`) autour duquel la silhouette grandit — le centre de sa propre boîte englobante vient s'y caler. Même convention que `zoom_blur`/`iris`. | centre du cadre | +| `from_scale` | `mask` uniquement : le facteur d'échelle à `progress: 0`. Une petite valeur positive (le défaut) laisse un point à peine visible, sans conséquence puisque `progress <= 0.0` retourne toujours la frame sortante brute, court-circuit compris. | `0.0` | +| `to_scale` | `mask` uniquement : le facteur d'échelle à `progress: 1`. **À la charge de l'auteur** — contrairement à `iris`, le rayon de couverture d'une silhouette arbitraire ne se résout pas automatiquement. Trop petit laisse un résidu de l'ancienne scène dans les coins. | `20.0` | +| `lobes`, `wobble`, `seed` | `blob` uniquement : nombre de lobes, amplitude du tremblement (fraction du rayon, `0` = cercle), et graine stable — même sélecteur que `pixel_dissolve.seed`. | `8`, `0.15`, `11` | +| `feather`, `band_color` | Communs à `mask`/`blob` et aux `wipe_*` — voir [rules/feathered-wipes.md](feathered-wipes.md). | `0`, absent | +| `duration`, `easing` | Communs à toutes les transitions. | `0.5`, `ease_in_out` | + +## `blob` : la même croissance, sans fournir la silhouette + +```json +{ + "transition": { + "type": "blob", + "duration": 0.6, + "lobes": 9, + "wobble": 0.15, + "seed": 3, + "origin": { "x": 960, "y": 320 } + } +} +``` + +`blob` construit `lobes` points autour d'un cercle, chacun décalé radialement par un bruit stable dérivé de `seed` (`wobble` en fixe l'amplitude), puis relie les points par des courbes quadratiques passant par leurs milieux — un contour lisse, sans coin dur. Contrairement à `mask`, le rayon de couverture est **résolu automatiquement**, comme celui d'`iris` : `wobble` fait que certains lobes sont plus courts que le rayon nominal, donc le rayon cible est divisé par `(1 - wobble)` pour garantir qu'même le lobe le plus rétréci atteigne le coin le plus éloigné de `origin` à `progress: 1`. + +`seed` (et `lobes`/`wobble`) fixent entièrement la forme : deux transitions avec les mêmes valeurs produisent des octets identiques. + +## Zéro aux deux bouts, par construction — pas par réglage fin + +Comme `zoom_blur` et `whip`, `mask_transition` et `blob_transition` commencent par un court-circuit : `progress <= 0.0` retourne la frame sortante **brute**, `progress >= 1.0` la frame entrante **brute**, avant tout calcul de silhouette ou de rayon. Pour `mask`, ça veut dire qu'un `from_scale` non nul (le défaut) ne laisse jamais un résidu visible au tout premier instant — le court-circuit l'emporte toujours sur la géométrie. Pour `blob`, la marge de sécurité sur le rayon (`/(1 - wobble)`) garantit la couverture bien avant `progress: 1`, et le court-circuit garantit l'exactitude pile à la borne. + +## Piège : `to_scale` trop petit sur `mask` + +Contrairement à `iris` (cercle ou stade, dont le rayon de couverture se calcule exactement depuis `origin` et les dimensions du cadre), une silhouette arbitraire n'a pas de formule générale pour « le facteur qui couvre tout le cadre ». Si `to_scale` est trop petit, `progress: 1` bascule quand même sur la frame entrante intacte (le court-circuit ne dépend pas de `to_scale`) — mais l'instant juste avant laisse un cadre visible de l'ancienne scène dans les coins que la silhouette n'a pas atteints. Ce n'est pas un bug : ajuster `to_scale` à la géométrie de la silhouette et du cadre est le prix de la généralité par rapport à `iris`. + +## Ce qui est réutilisé, ce qui ne l'est pas + +`mask_transition`/`blob_transition` réutilisent directement `iris_max_radius` (pour `blob`, en `IrisShape::Circle`) et le même schéma `origin` que `zoom_blur`/`iris`. `silhouette` reprend le vocabulaire `kind: polygon | path` de `style.clip-path`, y compris l'appel à `skia_safe::Path::from_svg` pour `path` — mais pas le type `ClipPath` lui-même, ni `clip_path_to_skia` (qui résout des `%` contre une *layout box*, une notion qui n'existe pas pour une transition compositant deux buffers déjà peints). La mise à l'échelle autour du centre de la boîte englobante de la silhouette, et le compositing feather/`band_color`, sont propres à cette transition. diff --git a/crates/rustmotion/skills/rules/shape-draw-start-and-path-morph.md b/crates/rustmotion/skills/rules/shape-draw-start-and-path-morph.md new file mode 100644 index 0000000..e35e281 --- /dev/null +++ b/crates/rustmotion/skills/rules/shape-draw-start-and-path-morph.md @@ -0,0 +1,76 @@ +# Rule: `shape.draw_start` et `shape.path_morph` — trim des deux bouts, et `d` animé + +Deux champs propres au composant `shape`, pas des propriétés `style.animation` génériques comme `draw_progress` — la nuance compte, voir plus bas. + +## `draw_start` : l'autre bout du trait + +`draw_progress` anime la **fin** du trait visible, toujours depuis le début du chemin. `draw_start` anime son **début** — le segment visible est le chemin entre `draw_start` et `draw_progress`, tous deux en fraction `0..1` de la longueur du chemin. + +```json +{ + "type": "shape", + "shape": { "type": "path", "data": "M0 80 C 20 40, 40 40, 50 10 C 60 40, 80 40, 90 80" }, + "stroke": { "color": "#F68F2B", "width": 6 }, + "draw_start": 0.5, + "style": { + "animation": [{ + "name": "keyframes", + "keyframes": [ + { "property": "draw_progress", "keyframes": [{ "time": 0, "value": 0 }, { "time": 0.5, "value": 1 }] } + ] + }] + } +} +``` + +`draw_start` accepte un nombre littéral ou une expression `"= …"` (même grammaire que `stroke.dash_offset`), réévaluée chaque frame contre `t`/`t_abs`/`duration`/`width`/`height`/`fps`. Pour un segment qui « marche » le long du trait : + +```json +"draw_start": "= max(0, (t - 0.9) / 0.5)" +``` + +### Ce n'est pas une propriété `keyframes` comme `draw_progress` + +`draw_progress` vit dans `AnimatedProperties`, résolu par le pipeline générique `animator.rs`/`style.animation` — la même mécanique que `opacity`, `translate_x`, etc. `draw_start` **n'y vit pas** : c'est un champ propre à `Shape`, résolu localement via `crate::expr::Computed`, exactement comme `stroke.dash_offset`. Une tentative de le piloter par un `keyframes` de `style.animation` avec `"property": "draw_start"` ne fait rien — ce nom n'existe pas dans `AnimatedProperties`. Deux raisons à ce choix : + +1. **Ownership** : `AnimatedProperties`/`animator.rs` appartenaient à un autre chantier au moment où `draw_start` a été ajouté ; y ajouter un champ (et ses points de dispatch : `merge`, lecture, écriture) n'était pas dans le périmètre de ce changement. +2. `Computed` couvre le même besoin sans dupliquer la mécanique de keyframes/easing d'`animator.rs` — au prix de perdre l'easing nommé (`ease_in_out_cubic`, etc.) au profit d'une expression écrite à la main. + +**Limite connue** : `svg`, `line`, `arrow` et `connector` n'ont pas cette capacité — seul `shape` l'a. Étendre `draw_start` à ces composants demande d'ajouter le champ à `AnimatedProperties` (avec son sentinel `-1.0`, son `merge`, ses deux points de dispatch par nom de propriété) et de le brancher dans chacun de ces fichiers. + +### Sans `draw_progress` actif, `draw_start` grignote depuis la fin implicite de `1.0` + +Si `draw_progress` n'anime jamais (reste au sentinel `-1.0`, c'est-à-dire absent), le trait est normalement dessiné en entier. `draw_start` s'applique quand même dans ce cas : la fin effective vaut `1.0`, donc `draw_start` seul efface progressivement la queue du trait sans qu'il ait besoin de croître depuis `0` au préalable. + +### Zéro à l'origine, comme `line`/`svg` + +Un `draw_start >= draw_progress` (fenêtre vide ou inversée) ne peint rien — la trimming passe par `trim_path_between`, une extraction géométrique réelle via `PathMeasure::get_segment`, pas un `PathEffect::dash` à intervalle nul. C'est précisément le piège documenté dans [rules/draw-progress-stroke.md](draw-progress-stroke.md) (un pointillé de longueur nulle avec un cap arrondi peint quand même un point) : `trim_path_between` ne produit jamais un tel pointillé, un chemin vide n'a aucun verbe à peindre. + +## `path_morph` : `d` animé, keyframe par keyframe + +```json +{ + "type": "shape", + "shape": { "type": "path", "data": "M10 80 C 20 40, 40 40, 50 10 C 60 40, 80 40, 90 80 Z" }, + "fill": "#8B5CF6", + "path_morph": { + "keyframes": [ + { "time": 0, "value": "M10 80 C 20 40, 40 40, 50 10 C 60 40, 80 40, 90 80 Z" }, + { "time": 0.6, "value": "M10 80 C 25 45, 35 35, 50 15 C 65 35, 75 45, 90 80 Z" } + ], + "easing": "ease_in_out", + "repeat": true, + "yoyo": true + } +} +``` + +`path_morph`, présent, **remplace entièrement** `shape` pour le rendu — fond et trait — tant qu'il est défini ; `shape.shape` sert seulement de secours si `path_morph` échoue à produire un chemin. `time` est en secondes depuis le début de la scène, comme partout ailleurs dans ce schéma — pas une fraction `0..1`. `repeat` boucle au premier keyframe une fois le dernier `time` dépassé ; `yoyo` (avec `repeat`) alterne le sens au lieu de revenir sèchement au début. + +### Structure incompatible : loud, pas silencieux + +L'interpolation ne marche que point par point, entre deux chemins qui partagent exactement la même suite de commandes (même verbes, dans le même ordre, avec le même nombre de points). Un décalage — un `Q` remplacé par un `L` à la même position, un sommet en plus — **écrit sur stderr** et retient la forme du keyframe précédent plutôt que d'interpoler n'importe quoi ou de planter, le même principe que `clip_path_to_skia` pour `kind: node-path` (voir [rules/clip-path.md](clip-path.md)) : rendre visible plutôt que deviner. Il n'y a pas de ré-échantillonnage automatique vers un nombre commun de segments cubiques — construire les deux `d` avec la même structure de commandes reste la responsabilité de l'auteur. + +### Ce qui est réutilisé + +`trim_path_between` et `interpolate_path_data` vivent dans `rustmotion_core::engine::renderer` (le même module que `build_shape_path`/`draw_shape_path`), pas seulement pour `shape` : ce sont des primitives Skia pures, sans rien de propre au composant, prêtes à être consommées par `svg`/`line`/`arrow`/`connector` le jour où `draw_start` leur est étendu.