diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 65f28f4..d247b02 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -270,6 +270,45 @@ pub fn ease(t: f64, easing: &EasingType) -> f64 { } } +pub fn chromatic_aberration_shift( + cfg: &crate::schema::ChromaticAberrationConfig, + progress: f32, +) -> f32 { + let eased = ease(progress as f64, &cfg.easing) as f32; + cfg.amount * (1.0 - eased) +} + +#[cfg(test)] +mod chromatic_aberration_shift_tests { + use super::*; + use crate::schema::{ChromaticAberrationConfig, EasingType}; + + fn cfg(amount: f32) -> ChromaticAberrationConfig { + ChromaticAberrationConfig { + delay: 0.0, + duration: 0.6, + amount, + easing: EasingType::Linear, + } + } + + #[test] + fn peaks_at_the_full_amount_when_progress_is_zero() { + assert_eq!(chromatic_aberration_shift(&cfg(6.0), 0.0), 6.0); + } + + #[test] + fn decays_to_exactly_zero_when_progress_reaches_one() { + assert_eq!(chromatic_aberration_shift(&cfg(6.0), 1.0), 0.0); + } + + #[test] + fn is_between_zero_and_the_amount_mid_flight() { + let shift = chromatic_aberration_shift(&cfg(6.0), 0.5); + assert!(shift > 0.0 && shift < 6.0, "got {shift}"); + } +} + fn cubic_bezier_ease(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { let t_curve = find_bezier_t_for_x(t, x1, x2); bezier_component(t_curve, y1, y2) diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 1b82711..4f579f6 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -269,12 +269,24 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .filter .as_deref() .and_then(|list| filters_to_image_filter(list, &length_ctx)); - let opened_opacity_layer = if opacity < 1.0 || content_filter.is_some() { + let aberration_shift = active_chromatic_aberration(&node.css, ctx.frame.time) + .map(|(cfg, progress)| crate::engine::animator::chromatic_aberration_shift(cfg, progress)); + let aberration_filter = aberration_shift.and_then(chromatic_aberration_image_filter); + let combined_filter = { + use skia_safe::image_filters; + match (content_filter, aberration_filter) { + (Some(cf), Some(af)) => image_filters::compose(af, cf), + (Some(cf), None) => Some(cf), + (None, Some(af)) => Some(af), + (None, None) => None, + } + }; + let opened_opacity_layer = if opacity < 1.0 || combined_filter.is_some() { let mut paint = Paint::default(); if opacity < 1.0 { paint.set_alpha((opacity * 255.0) as u8); } - if let Some(filter) = content_filter { + if let Some(filter) = combined_filter { paint.set_image_filter(filter); } let filter_bleed_px = node @@ -289,7 +301,10 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .as_deref() .map(|shadows| box_shadow_bleed(shadows, &length_ctx)) .unwrap_or(0.0); - let bleed = filter_bleed_px.max(shadow_bleed_px); + let aberration_bleed_px = aberration_shift.map(|s| s.abs().ceil()).unwrap_or(0.0); + let bleed = filter_bleed_px + .max(shadow_bleed_px) + .max(aberration_bleed_px); let mut bounds = Rect::from_xywh( box_layout.x - bleed, box_layout.y - bleed, @@ -484,6 +499,60 @@ fn paint_shimmer_band( ); } +fn active_chromatic_aberration( + css: &CssStyle, + time: f64, +) -> Option<(&crate::schema::ChromaticAberrationConfig, f32)> { + let cfg = css.animation.iter().find_map(|e| match e { + crate::schema::AnimationEffect::ChromaticAberration(c) => Some(c), + _ => None, + })?; + if cfg.duration <= 0.0 { + return None; + } + let elapsed = time - cfg.delay; + if elapsed < 0.0 || elapsed >= cfg.duration { + return None; + } + Some((cfg, (elapsed / cfg.duration) as f32)) +} + +fn chromatic_aberration_image_filter(shift: f32) -> Option { + if shift.abs() < 0.05 { + return None; + } + use skia_safe::{color_filters, image_filters, BlendMode}; + + #[rustfmt::skip] + const RED_ONLY: [f32; 20] = [ + 1.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + #[rustfmt::skip] + const CYAN_ONLY: [f32; 20] = [ + 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + + let red_shifted = image_filters::offset((-shift, 0.0), None, None)?; + let cyan_shifted = image_filters::offset((shift, 0.0), None, None)?; + let red = image_filters::color_filter( + color_filters::matrix_row_major(&RED_ONLY, None), + Some(red_shifted), + None, + )?; + let cyan = image_filters::color_filter( + color_filters::matrix_row_major(&CYAN_ONLY, None), + Some(cyan_shifted), + None, + )?; + image_filters::blend(BlendMode::Plus, Some(red), Some(cyan), None) +} + fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 { use crate::css::style::FilterFn; let mut bleed = 0.0f32; @@ -2639,6 +2708,7 @@ mod paint_order_tests { use crate::css::units::{Length, LengthPercentage as CLP}; use crate::engine::box_tree::{BoxKind, BoxNode}; use crate::engine::layout_pass::run_layout; + use crate::schema::{AnimationEffect, ChromaticAberrationConfig, EasingType}; fn test_frame(w: u32, h: u32) -> PaintFrame { PaintFrame { @@ -2988,6 +3058,135 @@ mod paint_order_tests { let far = probe(20, 20); assert_eq!(far, 0, "far corner must stay untouched, got r={far}"); } + + fn render_pixels_at(root: &mut BoxNode, w: u32, h: u32, time: f64) -> Vec { + root.assign_ids(0); + let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + let frame = PaintFrame { + time, + ..test_frame(w, h) + }; + paint_tree(surface.canvas(), root, &layout, &frame, &NoopDispatcher); + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0)); + buf + } + + fn white_square(animation: Vec) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(100.0)), + top: Some(CLP::Px(100.0)), + width: Some(CSize::Length(CLP::Px(120.0))), + height: Some(CSize::Length(CLP::Px(120.0))), + background: Some(Background::Color(CssColor::String("#ffffff".into()))), + animation, + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn probe(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8) { + let i = ((y * w + x) * 4) as usize; + (buf[i], buf[i + 1], buf[i + 2]) + } + + #[test] + fn chromatic_aberration_shows_a_red_fringe_on_one_edge_and_a_cyan_fringe_on_the_other() { + let cfg = ChromaticAberrationConfig { + delay: 0.0, + duration: 0.6, + amount: 10.0, + easing: EasingType::Linear, + }; + let mut root = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::ChromaticAberration( + cfg, + )])], + ); + let buf = render_pixels_at(&mut root, 400, 400, 0.3); + + let left_edge = probe(&buf, 400, 100, 160); + let right_edge = probe(&buf, 400, 220, 160); + assert!( + left_edge.0 > 200 && left_edge.1 < 50 && left_edge.2 < 50, + "expected a red-leaning fringe on the left edge mid-flight, got {left_edge:?}" + ); + assert!( + right_edge.0 < 50 && right_edge.1 > 200 && right_edge.2 > 200, + "expected a cyan-leaning fringe on the right edge mid-flight, got {right_edge:?}" + ); + } + + #[test] + fn chromatic_aberration_is_gone_by_the_end_of_the_animation() { + let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let baseline = render_pixels_at(&mut plain, 400, 400, 5.0); + + let cfg = ChromaticAberrationConfig { + delay: 0.0, + duration: 0.6, + amount: 10.0, + easing: EasingType::Linear, + }; + let mut animated = root_node( + 400.0, + 400.0, + "#000000", + vec![white_square(vec![AnimationEffect::ChromaticAberration( + cfg, + )])], + ); + let at_end = render_pixels_at(&mut animated, 400, 400, 0.6); + + assert_eq!( + baseline, at_end, + "the node must be pixel-identical to one with no effect at all once the \ + animation's duration has elapsed — no permanent fringe left behind" + ); + } + + #[test] + fn a_node_without_the_effect_is_untouched() { + let mut root_a = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let a = render_pixels_at(&mut root_a, 400, 400, 0.0); + let mut root_b = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]); + let b = render_pixels_at(&mut root_b, 400, 400, 5.0); + assert_eq!( + a, b, + "a node with no chromatic_aberration effect must not vary with time" + ); + + let left_edge = probe(&a, 400, 100, 160); + let right_edge = probe(&a, 400, 219, 160); + assert_eq!( + left_edge, + (255, 255, 255), + "no effect means no fringe on the left edge either, got {left_edge:?}" + ); + assert_eq!( + right_edge, + (255, 255, 255), + "no effect means no fringe on the right edge either, got {right_edge:?}" + ); + } } #[cfg(test)] diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs index 8443de9..7b6e2d9 100644 --- a/crates/rustmotion-core/src/schema/animation.rs +++ b/crates/rustmotion-core/src/schema/animation.rs @@ -262,6 +262,40 @@ fn default_preset_duration() -> f64 { 0.8 } +/// Per-element channel-split effect: the red and cyan (green+blue) channels +/// of the node's own rendered content separate by `amount` px and converge +/// back to a perfect overlap by the end of `duration` — the same "zero at +/// the end" guarantee `TransitionType::ChromaticWipe` gives its reveal edge +/// (see `engine::transition::chromatic_wipe`), so no node is left +/// permanently fringed once the effect has played. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ChromaticAberrationConfig { + /// Delay before the split starts (seconds). + #[serde(default)] + pub delay: f64, + /// How long the channels take to converge back to zero separation + /// (seconds). + #[serde(default = "default_chromatic_aberration_duration")] + pub duration: f64, + /// How far the red and cyan channels separate at the peak (px), reached + /// the instant the effect starts (`delay`) and decaying to `0` by + /// `delay + duration`. + #[serde(default = "default_chromatic_aberration_amount")] + pub amount: f32, + /// Easing applied to the decay from `amount` down to zero. + #[serde(default = "default_easing")] + pub easing: EasingType, +} + +fn default_chromatic_aberration_duration() -> f64 { + 0.6 +} + +fn default_chromatic_aberration_amount() -> f32 { + 6.0 +} + #[cfg(test)] mod deny_unknown_fields_tests { use super::*; @@ -312,4 +346,29 @@ mod deny_unknown_fields_tests { assert_eq!(k.time, 0.5); assert!(k.easing.is_some()); } + + #[test] + fn chromatic_aberration_config_rejects_unknown_fields() { + let json = json!({ "amount": 6.0, "duratoin": 0.6 }); + let err = serde_json::from_value::(json) + .expect_err("a typo'd field must be rejected, not silently ignored"); + assert!(err.to_string().contains("duratoin"), "got: {err}"); + } + + #[test] + fn chromatic_aberration_config_defaults() { + let json = json!({}); + let cfg: ChromaticAberrationConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.delay, 0.0); + assert_eq!(cfg.duration, default_chromatic_aberration_duration()); + assert_eq!(cfg.amount, default_chromatic_aberration_amount()); + } + + #[test] + fn chromatic_aberration_config_accepts_issue_shape() { + let json = json!({ "amount": 6, "duration": 0.6 }); + let cfg: ChromaticAberrationConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.amount, 6.0); + assert_eq!(cfg.duration, 0.6); + } } diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 7e29794..2f836ae 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -2,7 +2,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::Path as SkiaPath; -use super::animation::{Animation, AnimationPreset, EasingType, PresetConfig, SpringConfig}; +use super::animation::{ + Animation, AnimationPreset, ChromaticAberrationConfig, EasingType, PresetConfig, SpringConfig, +}; use super::style::{FontWeight, TextAlign, VerticalAlign}; /// A single animation effect. Discriminated by `"type"` in JSON. @@ -101,6 +103,10 @@ pub enum AnimationEffect { /// coordinate space path points are interpreted in, and how degenerate /// paths (empty, single-point, zero-length) are handled. MotionPath(MotionPathConfig), + /// Per-element channel-split: the node's own rendered content splits into + /// red/cyan fringes that converge back to zero separation by the end. + /// See [`ChromaticAberrationConfig`]'s doc comment. + ChromaticAberration(ChromaticAberrationConfig), } impl AnimationEffect { @@ -121,6 +127,7 @@ impl AnimationEffect { Keyframes(c) => c.delay += by, MotionPath(c) => c.delay += by, Shimmer(c) => c.delay += by, + ChromaticAberration(c) => c.delay += by, Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {} } } diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 02b9105..847fba8 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -235,6 +235,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/validate-json.md](rules/validate-json.md) - Always validate generated JSON with `rustmotion validate` before presenting - [rules/halo-shapes.md](rules/halo-shapes.md) - `halo` beyond circles: `radius_x`/`radius_y`/`rotation` for a wide thin band of light, and why the blur follows the short axis - [rules/zoom-blur-transition.md](rules/zoom-blur-transition.md) - The radial "tunnel" cut: `zoom_blur`'s `strength`/`origin`, why it had to be a transition and not an effect, and the pivot-coincident-edge trap +- [rules/chromatic-aberration.md](rules/chromatic-aberration.md) - Per-element red/cyan fringe on arrival: `chromatic_aberration`'s `amount`, how its curve differs from `chromatic_wipe`'s, and the `amount`-not-`amplitude` trap - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet - [rules/overlapping-scenes.md](rules/overlapping-scenes.md) - Make an element outlive a cut: overlapping `at` windows composite instead of replacing, who supplies the background, and why `snap` never creates an overlap diff --git a/crates/rustmotion/skills/rules/chromatic-aberration.md b/crates/rustmotion/skills/rules/chromatic-aberration.md new file mode 100644 index 0000000..391a477 --- /dev/null +++ b/crates/rustmotion/skills/rules/chromatic-aberration.md @@ -0,0 +1,54 @@ +# Rule: Chromatic Aberration (per-élément) + +`chromatic_wipe` (voir CLAUDE.md) sépare les canaux rouge/cyan **entre deux scènes**, comme composite de deux frame-buffers déjà rendus. `chromatic_aberration` fait la même séparation de canaux, mais comme effet d'animation sur **un seul nœud** — l'icône, la carte, le texte qui arrive avec un franc glitch chromatique avant de se stabiliser. Pas de transition, pas de deuxième frame : juste le contenu déjà peint de l'élément, dédoublé et décalé. + +## La forme + +```json +{ + "type": "icon", + "name": "zap", + "style": { + "animation": [{ + "name": "chromatic_aberration", + "amount": 6, + "duration": 0.6 + }] + } +} +``` + +| Champ | Rôle | Défaut | +|---|---|---| +| `delay` | Attente avant le début de la séparation (s) | `0` | +| `duration` | Temps pour revenir à une séparation nulle (s) | `0.6` | +| `amount` | Écart maximal entre les canaux, en px, atteint dès `delay` | `6` | +| `easing` | Courbe appliquée à la décroissance | `ease_out` | + +## Ce n'est pas un aller-retour + +Contrairement à `chromatic_wipe`, dont le pic se situe **au milieu** de la transition (nul aux deux bouts, pour ne pas laisser de frange sur la scène suivante), `chromatic_aberration` est **maximal dès le premier instant** (`delay`) et décroît vers zéro à `delay + duration`. C'est un effet d'arrivée — l'élément se matérialise dans un éclat chromatique puis se stabilise — pas un flash symétrique. Après `delay + duration`, l'élément est pixel pour pixel identique à un élément sans l'effet : aucune frange ne reste accrochée. + +Si tu veux un flash qui pique au milieu plutôt qu'au début, compose deux `chromatic_aberration` avec des `delay` décalés ou pilote `amount` via ta propre courbe — le champ n'accepte qu'une seule forme de décroissance (pic au début). + +## Comment c'est peint + +Le contenu du nœud (fond, bordure, enfants, `shimmer`…) est peint une seule fois dans le layer d'opacité du nœud, puis un `ImageFilter` Skia recompose deux copies décalées de ce layer : une isolée sur le canal rouge décalée d'un côté, une isolée sur le canal cyan (vert+bleu) décalée de l'autre, sommées en mode `Plus`. Loin des bords de l'élément les deux copies se recouvrent et reconstituent la couleur d'origine exactement ; c'est seulement à la frontière — où l'une des deux copies échantillonne en dehors du contenu peint — qu'une frange colorée apparaît. + +Conséquence pratique : c'est le même mécanisme que `style.filter` (blur, drop-shadow…) — un filtre d'image posé sur le layer du nœud — donc ça se compose avec un `style.filter` existant sur le même nœud, et ça hérite des mêmes règles de bleed : + +- Un parent `overflow: hidden` clippe la frange, comme il clipperait un flou qui déborde. +- Le propre `overflow: hidden` du nœud ne la clippe **pas** — il ne clippe que les enfants, jamais le contenu du nœud lui-même (même règle que pour son ombre portée sortante). + +## Piège : `amount`, pas `amplitude` + +`float_3d` et les presets oscillants voisins utilisent `amplitude` pour leur intensité en px. `chromatic_aberration` n'est pas un preset de la même famille — c'est un effet à config dédiée comme `shimmer`, `glow` ou `wiggle` — et son champ s'appelle **`amount`**. `amplitude` sur `chromatic_aberration` est un champ inconnu, rejeté par le schéma. + +De la même façon, `chromatic_aberration` ne prend ni `spring`, ni `overshoot`, ni `loop` / `repeat` : ce sont des champs de `AnimationTiming`/`PresetConfig` (la famille `fade_in`, `scale_in`, …), pas de cette config. + +## Cas dégénérés + +- `amount: 0` (ou négatif au point de devenir imperceptible) : aucune frange visible, pas d'erreur — l'effet est simplement inerte. +- `amount` négatif mais non nul : la séparation est réelle, seuls les côtés rouge/cyan s'inversent. +- `duration: 0` (ou négative) : l'effet ne s'active jamais — traité comme absent à chaque frame. +- `delay` seul ne rejoue jamais l'effet : il n'y a pas de `loop` ici, une seule décroissance par nœud. diff --git a/crates/rustmotion/src/cli/commands/validate_schema.rs b/crates/rustmotion/src/cli/commands/validate_schema.rs index 538a630..34995e0 100644 --- a/crates/rustmotion/src/cli/commands/validate_schema.rs +++ b/crates/rustmotion/src/cli/commands/validate_schema.rs @@ -664,6 +664,8 @@ fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { } } + AnimationEffect::ChromaticAberration(c) => Some((c.delay, c.duration)), + AnimationEffect::Glow(_) | AnimationEffect::Wiggle(_) | AnimationEffect::Orbit(_)