Skip to content

Commit 194057d

Browse files
authored
feat(engine): dynamic-video foundations — transform origins, glass kit, spring presets (#96)
Closes #86, #87, #88. Real transform/perspective origins with dual-pivot 3D, gradient-border painted + FilterFn::Noise (deterministic Skia grain) + zombie compat fields with validator warnings, spring easing on any preset via a generic motion-keyframe post-pass.
1 parent bf1ae4f commit 194057d

16 files changed

Lines changed: 1596 additions & 90 deletions

File tree

.claude/skills/rustmotion/SKILL.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,13 +2247,24 @@ New style fields available on all components:
22472247

22482248
| Style field | Type | Default | Description |
22492249
| ----------------- | ------ | ------- | -------------------------------------------------------- |
2250-
| `backdrop-blur` | f32 | `null` | Glassmorphism blur effect (pixels) |
2251-
| `gradient-border` | object | `null` | `{ "colors": [...], "width": 2, "angle": 0 }` — gradient-colored border |
2252-
| `inner-shadow` | object | `null` | `{ "color": "#000", "offset_x": 0, "offset_y": 0, "blur": 10 }` — inset shadow |
2250+
| `gradient-border` | object | `null` | `{ "colors": ["#f00", "#00f"], "width": 2, "angle": 0 }` — gradient-colored border ring, border-radius aware, painted instead of `border` when both are set |
22532251
| `motion-path` | string | `null` | SVG path string that the element follows during animation |
22542252
| `stagger` | f32 | `null` | Auto-delay offset per child in a container (seconds) |
22552253
| `timeline` | array | `[]` | Intra-scene timeline steps — sequential animation phases |
22562254

2255+
**Deprecated (accepted but never rendered — the validator warns):**
2256+
2257+
| Legacy field | Use instead |
2258+
| --------------- | ------------------------------------------------------------ |
2259+
| `backdrop-blur` | `backdrop-filter: [{ "fn": "blur", "radius": N }]` |
2260+
| `inner-shadow` | `box-shadow: [{ ..., "inset": true }]` |
2261+
2262+
**Film grain (`noise` filter):** works in both `filter` and `backdrop-filter` chains. Deterministic — same `seed` produces identical grain on every frame.
2263+
2264+
```json
2265+
{ "backdrop-filter": [{ "fn": "blur", "radius": 24 }, { "fn": "noise", "intensity": 0.15, "seed": 42 }] }
2266+
```
2267+
22572268
---
22582269

22592270
### 3D Perspective Transforms
@@ -2269,7 +2280,7 @@ Any component can be rendered with true 3D perspective using keyframe animations
22692280
"height": 400,
22702281
"background": "#FFFFFF08",
22712282
"border-radius": 24,
2272-
"backdrop-blur": 15,
2283+
"backdrop-filter": [{ "fn": "blur", "radius": 15 }],
22732284
"border": { "color": "#FFFFFF14", "width": 1 },
22742285
"box-shadow": { "color": "#00000060", "offset_x": 0, "offset_y": 20, "blur": 60 },
22752286
"animation": [{

.claude/skills/rustmotion/rules/3d-perspective.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,34 @@ Utilise la propriété `"name": "keyframes"` avec les propriétés `rotate_x`, `
8282

8383
Les rotations >30° déforment le contenu et rendent le texte illisible. Rester dans −30..30.
8484

85+
### Pivot configurable : `transform-origin` et `perspective-origin`
86+
87+
Le pivot de rotation/scale est configurable via `style["transform-origin"]` (et `style["perspective-origin"]` pour le point de fuite de la perspective). Les deux propriétés acceptent :
88+
89+
- Mots-clés : `"left"`, `"center"`, `"right"` (axe X) ; `"top"`, `"center"`, `"bottom"` (axe Y)
90+
- Pourcentages : `"0%"` (coin), `"50%"` (centre, défaut), `"100%"` (bord opposé)
91+
- Valeurs px : ex. `"20px"` (relatif au coin haut-gauche de la box)
92+
93+
```json
94+
{
95+
"style": {
96+
"transform-origin": { "x": "left", "y": "center" },
97+
"animation": [
98+
{
99+
"name": "keyframes",
100+
"keyframes": [
101+
{ "property": "rotate_y", "keyframes": [{ "time": 0, "value": -45 }, { "time": 1, "value": 0 }] },
102+
{ "property": "perspective", "keyframes": [{ "time": 0, "value": 800 }, { "time": 1, "value": 800 }] }
103+
]
104+
}
105+
]
106+
}
107+
}
108+
```
109+
110+
L'absence de `transform-origin` = pivot au centre de la box (comportement par défaut, inchangé).
111+
`perspective-origin` par défaut = même pivot que `transform-origin`.
112+
85113
---
86114

87115
## Shadow 3D adaptatif (automatique)

.claude/skills/rustmotion/rules/easing-guidelines.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,15 @@ When easing is `spring`, configure with:
2525
"spring": { "damping": 15, "stiffness": 100, "mass": 1 }
2626
}
2727
```
28+
29+
**Tout preset accepte `spring`.** Ajouter un objet `spring` à n'importe quel preset applique la physique de ressort à ses keyframes de mouvement (translate/scale/rotate) — l'opacity garde son ease (pas de flash d'alpha en overshoot) :
30+
31+
```json
32+
{ "name": "fade_in_up", "duration": 0.8, "spring": { "damping": 8, "stiffness": 120 } }
33+
```
34+
35+
- `bounce_in` / `elastic_in` : leurs springs intégrés sont les défauts ; un `spring` utilisateur les remplace.
36+
- `scale_in` + spring : l'overshoot manuel est remplacé par celui du ressort.
37+
- Oscillateurs continus (`pulse`, `shake`, `float`) : non affectés (leur forme est leur raison d'être).
38+
- La `duration` reste la fenêtre de l'animation : le ressort est résolu en secondes réelles dans cette fenêtre et la valeur se cale sur la cible à la fin — choisir une duration suffisante (≥ 0.6s avec les défauts) pour laisser le ressort converger.
39+
- Dialecte HTML : la DSL compacte accepte `spring:true` (défauts damping 15 / stiffness 100 / mass 1) ; la config fine passe par la forme JSON de `anim`.

.claude/skills/rustmotion/rules/glassmorphism.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Sans bordure, la carte flotte sans contour visible.
1919
Sans ombre, elle ne se détache pas du fond.
2020

2121
**Propriété correcte : `backdrop-filter`, pas `backdrop-blur`.**
22-
`backdrop-blur` n'existe pas dans CssStyle — utiliser `backdrop-filter` avec `{ "fn": "blur", "radius": N }`.
22+
`backdrop-blur` est accepté pour compat mais **jamais rendu** (le validateur émet un warning) — utiliser `backdrop-filter` avec `{ "fn": "blur", "radius": N }`. Idem pour `inner-shadow``box-shadow` avec `"inset": true`.
2323

2424
---
2525

@@ -82,6 +82,43 @@ Le `box-shadow` inset blanc (`offset-y: -1`) simule un reflet de lumière en hau
8282

8383
---
8484

85+
## Grain « frosted glass » : le filtre `noise`
86+
87+
Le vrai verre dépoli a une micro-texture. Le filtre `noise` (déterministe : même `seed` = même grain à chaque frame) s'ajoute à la chaîne `backdrop-filter` après le blur :
88+
89+
```json
90+
{
91+
"backdrop-filter": [
92+
{ "fn": "blur", "radius": 24 },
93+
{ "fn": "noise", "intensity": 0.12, "seed": 42 }
94+
]
95+
}
96+
```
97+
98+
| Paramètre | Défaut | Plage utile |
99+
|---|---|---|
100+
| `intensity` | 0.15 | 0.08–0.20 (subtil), 0.25–0.40 (texture visible) |
101+
| `seed` | 42 | n'importe quel entier — varier entre panels pour éviter un grain identique |
102+
103+
Le filtre marche aussi dans `filter` (grain sur le contenu de l'élément lui-même, style pellicule photo).
104+
105+
---
106+
107+
## Bordure gradient : `gradient-border`
108+
109+
Alternative premium à la bordure translucide unie — un anneau dégradé, border-radius aware, peint **à la place** de `border` quand les deux sont présents :
110+
111+
```json
112+
{
113+
"border-radius": 32,
114+
"gradient-border": { "colors": ["#FFFFFF50", "#FFFFFF08"], "width": 1.5, "angle": 180 }
115+
}
116+
```
117+
118+
L'angle suit la même convention que les gradients `background`. Un dégradé blanc→transparent vertical simule la lumière qui accroche le haut de la carte.
119+
120+
---
121+
85122
## Fond : les éléments qui brillent à travers le verre
86123

87124
Le glassmorphisme n'a d'intérêt que s'il y a quelque chose à voir derrière. Placer des blobs colorés **derrière** la carte (`z-index: 0`) :

.claude/skills/rustmotion/rules/prefer-presets.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ Presets are simpler, less error-prone, and produce consistent motion design. Onl
3535
| Char (text only) | `char_scale_in`, `char_fade_in`, `char_wave`, `char_bounce`, `char_rotate_in`, `char_slide_up` |
3636

3737
`scale_in` and `scale_out` support `overshoot` (default 0.08 = 8%). Char presets support `stagger`, `granularity`, `easing`, and `overshoot`.
38+
39+
**Tout preset standard accepte `spring`** — un objet `{ "damping", "stiffness", "mass" }` qui remplace la courbe de mouvement du preset (translate/scale/rotate) par une physique de ressort ; l'opacity garde son ease. Exemple : `{ "name": "slide_in_left", "spring": { "damping": 10 } }`. Détails dans easing-guidelines.md. (Char presets et `tilt_in` : hors scope v1.)

crates/rustmotion-cli/src/commands/validate_schema.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,20 @@ fn validate_children(
7575
p
7676
));
7777
}
78+
if style.backdrop_blur.is_some() {
79+
warnings.push(format!(
80+
"{}: style.backdrop-blur is accepted but not rendered — use \
81+
backdrop-filter: [{{\"fn\": \"blur\", \"radius\": N}}]",
82+
p
83+
));
84+
}
85+
if style.inner_shadow.is_some() {
86+
warnings.push(format!(
87+
"{}: style.inner-shadow is accepted but not rendered — use \
88+
box-shadow with \"inset\": true",
89+
p
90+
));
91+
}
7892

7993
if let Some(timed) = child.component.as_timed() {
8094
let (start, end) = timed.timing();
@@ -375,6 +389,36 @@ mod style_warning_tests {
375389
);
376390
}
377391

392+
#[test]
393+
fn warns_on_legacy_backdrop_blur_and_inner_shadow() {
394+
// Legacy glassmorphism fields are accepted for compat but never
395+
// rendered; validate must point at the working CSS equivalents.
396+
let child: ChildComponent = serde_json::from_value(serde_json::json!({
397+
"type": "card",
398+
"style": {
399+
"backdrop-blur": 20,
400+
"inner-shadow": { "color": "#000000", "offset_y": 2, "blur": 8 }
401+
}
402+
}))
403+
.unwrap();
404+
let mut errors = Vec::new();
405+
let mut warnings = Vec::new();
406+
validate_children(&[child], "test", 4.0, &mut errors, &mut warnings);
407+
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
408+
assert!(
409+
warnings
410+
.iter()
411+
.any(|w| w.contains("backdrop-blur") && w.contains("backdrop-filter")),
412+
"missing backdrop-blur warning: {warnings:?}"
413+
);
414+
assert!(
415+
warnings
416+
.iter()
417+
.any(|w| w.contains("inner-shadow") && w.contains("inset")),
418+
"missing inner-shadow warning: {warnings:?}"
419+
);
420+
}
421+
378422
#[test]
379423
fn time_scale_zero_is_an_error() {
380424
let child: ChildComponent = serde_json::from_value(serde_json::json!({

crates/rustmotion-components/src/legacy_dispatch.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,7 @@ mod tests {
375375
height: Some(CSize::Length(CLP::Px(100.0))),
376376
animation: vec![AnimationEffect::FadeIn(AnimationTiming {
377377
duration: 0.5,
378-
delay: 0.0,
379-
repeat: false,
380-
overshoot: None,
378+
..Default::default()
381379
})],
382380
..Default::default()
383381
},

crates/rustmotion-components/src/lib.rs

Lines changed: 0 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -494,58 +494,3 @@ impl Component {
494494
}
495495
}
496496
}
497-
498-
/// Draw a gradient border on a rounded rectangle.
499-
pub fn draw_gradient_border(
500-
canvas: &skia_safe::Canvas,
501-
rrect: &skia_safe::RRect,
502-
gb: &rustmotion_core::schema::GradientBorder,
503-
) {
504-
use skia_safe::{gradient_shader::GradientShaderColors, Paint, PaintStyle, Point};
505-
506-
if gb.colors.len() < 2 {
507-
return;
508-
}
509-
510-
let bounds = rrect.bounds();
511-
let angle_rad = gb.angle * std::f32::consts::PI / 180.0;
512-
let cx = bounds.center_x();
513-
let cy = bounds.center_y();
514-
let half_diag = (bounds.width().powi(2) + bounds.height().powi(2)).sqrt() / 2.0;
515-
516-
let start = Point::new(
517-
cx - angle_rad.cos() * half_diag,
518-
cy - angle_rad.sin() * half_diag,
519-
);
520-
let end = Point::new(
521-
cx + angle_rad.cos() * half_diag,
522-
cy + angle_rad.sin() * half_diag,
523-
);
524-
525-
let colors: Vec<skia_safe::Color> = gb
526-
.colors
527-
.iter()
528-
.map(|c| {
529-
let (r, g, b, a) = rustmotion_core::engine::renderer::parse_hex_color(c);
530-
skia_safe::Color::from_argb(a, r, g, b)
531-
})
532-
.collect();
533-
534-
let shader = skia_safe::shader::Shader::linear_gradient(
535-
(start, end),
536-
GradientShaderColors::Colors(&colors),
537-
None,
538-
skia_safe::TileMode::Clamp,
539-
None,
540-
None,
541-
);
542-
543-
if let Some(shader) = shader {
544-
let mut paint = Paint::default();
545-
paint.set_style(PaintStyle::Stroke);
546-
paint.set_stroke_width(gb.width);
547-
paint.set_anti_alias(true);
548-
paint.set_shader(shader);
549-
canvas.draw_rrect(rrect, &paint);
550-
}
551-
}

crates/rustmotion-core/src/css/style.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ use schemars::JsonSchema;
88
use serde::{Deserialize, Serialize};
99

1010
use super::units::{Length, LengthPercentage};
11-
use crate::schema::{deserialize_animation_effects, AnimationEffect};
11+
// `GradientBorder` / `InnerShadow` are reused from the schema layer rather
12+
// than mirrored: same crate, same serde/JsonSchema derives, identical JSON
13+
// shape either way — a css-local mirror would only duplicate the struct.
14+
use crate::schema::{deserialize_animation_effects, AnimationEffect, GradientBorder, InnerShadow};
1215

1316
/// Top-level CSS style block. All fields are optional; `None` means "not set"
1417
/// and lets the cascade fill in inherited / initial values.
@@ -80,6 +83,16 @@ pub struct CssStyle {
8083
pub opacity: Option<f32>,
8184
pub mix_blend_mode: Option<BlendMode>,
8285
pub clip_path: Option<ClipPath>,
86+
/// Gradient-colored border painted instead of `border` when present.
87+
/// `{ "colors": [...], "width": 2, "angle": 0 }` — angle follows the same
88+
/// convention as `background` linear gradients.
89+
pub gradient_border: Option<GradientBorder>,
90+
91+
// ---- Legacy compat (accepted, never rendered — validator warns) ----
92+
/// Deprecated: use `backdrop-filter: [{ "fn": "blur", "radius": N }]`.
93+
pub backdrop_blur: Option<f32>,
94+
/// Deprecated: use `box-shadow` with `"inset": true`.
95+
pub inner_shadow: Option<InnerShadow>,
8396

8497
// ---- Filters / effects ----
8598
pub filter: Option<Vec<FilterFn>>,
@@ -1011,6 +1024,24 @@ pub enum FilterFn {
10111024
Opacity {
10121025
value: f32,
10131026
},
1027+
/// Deterministic film-grain noise. Works in both `filter` and
1028+
/// `backdrop-filter` chains (frosted-glass grain).
1029+
Noise {
1030+
/// Grain strength in 0..1 (alpha of the noise layer). Default 0.15.
1031+
#[serde(default = "default_noise_intensity")]
1032+
intensity: f32,
1033+
/// Perlin-noise seed — same seed ⇒ identical grain on every frame.
1034+
#[serde(default = "default_noise_seed")]
1035+
seed: u64,
1036+
},
1037+
}
1038+
1039+
fn default_noise_intensity() -> f32 {
1040+
0.15
1041+
}
1042+
1043+
fn default_noise_seed() -> u64 {
1044+
42
10141045
}
10151046

10161047
// ---- Blend ----

crates/rustmotion-core/src/css/units.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,23 @@ impl ParsedLength {
9090
}
9191
}
9292

93+
/// Parse a CSS length/percentage string that may also contain transform-origin
94+
/// axis keywords (`left`, `center`, `right`, `top`, `bottom`).
95+
///
96+
/// Keywords are normalised to `ParsedLength::Percent` so that the regular
97+
/// `resolve` machinery handles them — the caller must still choose the correct
98+
/// axis dimension (width for x, height for y) in the `LengthContext`.
99+
pub fn parse_origin_component(s: &str) -> Option<ParsedLength> {
100+
let lower = s.trim().to_ascii_lowercase();
101+
match lower.as_str() {
102+
"left" | "top" => return Some(ParsedLength::Percent(0.0)),
103+
"center" => return Some(ParsedLength::Percent(50.0)),
104+
"right" | "bottom" => return Some(ParsedLength::Percent(100.0)),
105+
_ => {}
106+
}
107+
parse_length(s)
108+
}
109+
93110
/// Parse a CSS length/percentage string. Whitespace tolerated.
94111
pub fn parse_length(s: &str) -> Option<ParsedLength> {
95112
let s = s.trim();

0 commit comments

Comments
 (0)