Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions crates/rustmotion-core/src/engine/animator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
205 changes: 202 additions & 3 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<skia_safe::ImageFilter> {
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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<u8> {
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<AnimationEffect>) -> 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)]
Expand Down
59 changes: 59 additions & 0 deletions crates/rustmotion-core/src/schema/animation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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::<ChromaticAberrationConfig>(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);
}
}
9 changes: 8 additions & 1 deletion crates/rustmotion-core/src/schema/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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(_) => {}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading