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
70 changes: 70 additions & 0 deletions .claude/skills/rustmotion/rules/card-parallax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Parallaxe des cards et des blocs de texte

**Règle : dès qu'une scène contient plusieurs cards, elles doivent dériver
lentement en vertical les unes par rapport aux autres.** Une card seule à
l'écran n'a rien contre quoi faire parallaxe : elle reste immobile.

Ce qui distingue la profondeur de la chorégraphie n'est pas le mouvement
lui-même mais sa **divergence**. Des cards qui montent et descendent ensemble
lisent comme une danse — c'est le défaut le plus visible et le plus vite
fatigant. Les mêmes cards sur des phases et des amplitudes différentes lisent
comme des plans à des distances différentes.

## Les deux leviers

`float_3d` en `loop` expose trois champs qui doivent **tous** varier d'une card
à l'autre :

| champ | rôle | plage utile |
|---|---|---|
| `amplitude` | course verticale en px — le plan proche bouge plus que le lointain | 5 → 12 |
| `duration` | période d'un cycle | 6 → 11 s |
| `delay` | décalage de phase | ~1.4 s × index |

```json
{ "name": "float_3d", "loop": true, "duration": 7.3,
"delay": 1.37, "amplitude": 9.0 }
```

## Choisir les périodes

Les périodes doivent être **non harmoniques**, sinon le groupe se resynchronise
au bout de quelques cycles et la danse revient. `7.3 / 9.1 / 6.1 / 8.3 / 10.7`
n'ont pas de petit multiple commun. Éviter `6 / 8 / 10`, qui se recalent toutes
les 120 s — mais surtout éviter d'utiliser la même période partout.

## Amplitude : léger

Au-delà de ~12 px le mouvement se regarde au lieu de se ressentir, et il entre
en concurrence avec l'animation d'entrée et le pan caméra. La caméra fournit
déjà le mouvement principal ; la parallaxe ne fait qu'écarter les plans.

## Les blocs de texte aussi

La règle s'étend aux `text` : chaque bloc dérive, mais **moins que les cards**
(2,5 → 5 px) et sur des cycles plus longs (10 → 16 s). La typographie est le
plan le plus éloigné de l'objectif, et un titre qui oscille visiblement se lit
mal. Les lignes empilées d'un même titre doivent tirer des phases distinctes,
sinon le titre bouge comme un panneau rigide.

## Vérifier plutôt que juger à l'œil

L'unisson est difficile à voir sur une lecture et évident sur une mesure. Suivre
le centroïde vertical de deux cards sur une fenêtre stabilisée et corréler les
deux séries : **+1.00 = unisson**, à corriger. En dessous de ~0.6 la divergence
est acquise.

**Piège de mesure — le pan caméra est un mode commun.** La caméra déplace tous
les éléments ensemble, et ce mouvement est d'un ordre de grandeur supérieur à
une dérive de 4 px : corréler les positions absolues renvoie +0.99 même quand
la parallaxe fonctionne parfaitement. Mesurer l'**écart** entre deux éléments,
qui annule le mouvement commun : écart-type nul = bloc rigide, quelques
pixels = parallaxe réelle.

## Piège historique

`float_3d` ignorait `delay` et `duration` : ses keyframes étaient figées à
0.0 / 0.5 / 1.0 s, donc tout élément flottant partageait un cycle d'une seconde,
en phase, quoi que demande le scénario. Les scénarios écrits avant ce correctif
passaient une `duration` sans effet — les relire plutôt que supposer qu'ils
appliquent déjà la règle.
1 change: 1 addition & 0 deletions crates/rustmotion-components/src/box_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2260,6 +2260,7 @@ mod tests {
use rustmotion_core::css::units::Length;
let counter = ChildComponent {
component: Component::Counter(Counter {
duration: None,
from: 0.0,
to: 1234.0,
decimals: 0,
Expand Down
101 changes: 91 additions & 10 deletions crates/rustmotion-components/src/counter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ pub struct Counter {
pub suffix: Option<String>,
#[serde(default)]
pub easing: EasingType,
/// Seconds the count takes to run, measured from `start_at`.
///
/// Left unset the count stretches over whatever remains of the scene, so it
/// only reaches `to` on the very last frame and the viewer never gets to
/// read the figure they were counting towards. Setting this shorter than
/// the scene makes the count land early and hold.
#[serde(default)]
pub duration: Option<f64>,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
Expand All @@ -53,6 +61,24 @@ rustmotion_core::impl_traits!(Counter {
});

impl Counter {
/// Where the count sits on its 0..1 ramp at `time`, before easing.
///
/// The ramp starts at `start_at` and runs for `duration`, falling back to
/// the rest of the scene when no duration is given.
fn ramp_progress(&self, time: f64, scene_duration: f64) -> f64 {
let start = self.timing.start_at.unwrap_or(0.0);
let elapsed = (time - start).max(0.0);
let ramp = match self.duration {
Some(d) if d > 0.0 => d,
_ => scene_duration - start,
};
if ramp > 0.0 {
(elapsed / ramp).clamp(0.0, 1.0)
} else {
1.0
}
}

fn paint(
&self,
canvas: &Canvas,
Expand Down Expand Up @@ -91,16 +117,7 @@ impl Counter {
_ => TextAlign::Left,
};

let start = self.timing.start_at.unwrap_or(0.0);
let elapsed = (time - start).max(0.0);
let remaining_duration = scene_duration - start;
let t = if remaining_duration > 0.0 {
(elapsed / remaining_duration).clamp(0.0, 1.0)
} else {
1.0
};

let progress = ease(t, &self.easing);
let progress = ease(self.ramp_progress(time, scene_duration), &self.easing);
let value = self.from + (self.to - self.from) * progress;
let content = format_counter_value(
value,
Expand Down Expand Up @@ -263,3 +280,67 @@ impl Painter for Counter {
);
}
}

#[cfg(test)]
mod tests {
use super::*;

fn counter(duration: Option<f64>, start_at: Option<f64>) -> Counter {
Counter {
from: 0.0,
to: 100.0,
decimals: 0,
separator: None,
prefix: None,
suffix: None,
easing: EasingType::default(),
duration,
timing: TimingConfig { start_at, end_at: None },
style: CssStyle::default(),
timeline: Vec::new(),
stagger: None,
text_shadow: None,
stroke: None,
}
}

#[test]
fn without_duration_the_count_only_lands_on_the_last_frame() {
// The behaviour that made counters unreadable: nothing settles early,
// so the figure is still moving when the scene cuts away.
let c = counter(None, None);
assert!(c.ramp_progress(3.9, 4.0) < 1.0);
assert_eq!(c.ramp_progress(4.0, 4.0), 1.0);
}

#[test]
fn duration_makes_the_count_land_early_and_hold() {
let c = counter(Some(1.5), None);
assert_eq!(c.ramp_progress(1.5, 4.0), 1.0);
// Held for the rest of the scene, which is the point.
assert_eq!(c.ramp_progress(3.0, 4.0), 1.0);
assert!((c.ramp_progress(0.75, 4.0) - 0.5).abs() < 1e-9);
}

#[test]
fn duration_is_measured_from_start_at() {
let c = counter(Some(2.0), Some(1.0));
assert_eq!(c.ramp_progress(1.0, 6.0), 0.0);
assert!((c.ramp_progress(2.0, 6.0) - 0.5).abs() < 1e-9);
assert_eq!(c.ramp_progress(3.0, 6.0), 1.0);
}

#[test]
fn a_duration_outlasting_the_scene_is_honoured_not_clamped() {
// Deliberate: the author asked for a slow count, and silently speeding
// it up would be a surprise. It simply never reaches `to`.
let c = counter(Some(10.0), None);
assert!(c.ramp_progress(4.0, 4.0) < 0.5);
}

#[test]
fn a_zero_or_negative_duration_falls_back_to_the_scene() {
let c = counter(Some(0.0), None);
assert!((c.ramp_progress(2.0, 4.0) - 0.5).abs() < 1e-9);
}
}
44 changes: 38 additions & 6 deletions crates/rustmotion-core/src/engine/animator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1380,12 +1380,25 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec<A
],

// ── Floating/orbit ────────────────────────────────────────────
AnimationPreset::Float3d => vec![
kf_anim_3kf("position.y", 0.0, -12.0, 0.0, EasingType::EaseInOut),
kf_anim_3kf("rotate_x", 0.0, 5.0, 0.0, EasingType::EaseInOut),
kf_anim_3kf("rotate_y", 0.0, -8.0, 0.0, EasingType::EaseInOut),
kf_anim("perspective", 0.0, 1000.0, 1.0, 1000.0, EasingType::Linear),
],
AnimationPreset::Float3d => {
// The cycle spans delay..delay+duration, so `duration` sets the
// period and `delay` shifts the phase.
//
// Both were previously inert: the keyframes were pinned to 0.0 /
// 0.5 / 1.0 seconds, so every floating element in a scene shared
// one 1-second cycle and moved in lockstep no matter what the
// scenario asked for. A row of cards bobbing in unison reads as a
// dance; the same cards on different phases and travels read as
// depth, which is the point of the preset.
let amp = config.amplitude.unwrap_or(12.0);
let tilt = amp / 12.0;
vec![
kf_anim_3kf_over("position.y", delay, end, 0.0, -amp, 0.0, EasingType::EaseInOut),
kf_anim_3kf_over("rotate_x", delay, end, 0.0, 5.0 * tilt, 0.0, EasingType::EaseInOut),
kf_anim_3kf_over("rotate_y", delay, end, 0.0, -8.0 * tilt, 0.0, EasingType::EaseInOut),
kf_anim("perspective", delay, 1000.0, end, 1000.0, EasingType::Linear),
]
}

// ── Spéciaux ────────────────────────────────────────────────────
AnimationPreset::DrawIn => vec![kf_anim(
Expand Down Expand Up @@ -1483,6 +1496,25 @@ fn kf_anim_spring_underdamped(property: &str, t0: f64, v0: f64, t1: f64, v1: f64
}
}

/// Three-keyframe oscillation laid out over an explicit `start..end` window,
/// so the caller controls both when it begins and how long one cycle lasts.
fn kf_anim_3kf_over(
property: &str,
start: f64,
end: f64,
v0: f64,
v1: f64,
v2: f64,
easing: EasingType,
) -> Animation {
Animation {
property: property.to_string(),
keyframes: vec![kf(start, v0), kf((start + end) / 2.0, v1), kf(end, v2)],
easing,
spring: None,
}
}

fn kf_anim_3kf(property: &str, v0: f64, v1: f64, v2: f64, easing: EasingType) -> Animation {
Animation {
property: property.to_string(),
Expand Down
66 changes: 64 additions & 2 deletions crates/rustmotion-core/src/engine/transition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,26 @@ pub fn camera_pan_transition(

let canvas = surface.canvas();
canvas.draw_image(&img_bg, (0.0, 0.0), None);
canvas.draw_image(&img_fg_a, (out_x, out_y), None);
canvas.draw_image(&img_fg_b, (in_x, in_y), None);

// The scene being left behind dissolves rather than sliding off as a solid
// slab, and the arriving one materialises. Drift alone gives the two planes
// different speeds; letting them also come and go is what reads as depth
// instead of a sheet of paper being pulled sideways.
//
// Both curves are pinned at their own end — `fg_a` is fully opaque at t=0,
// `fg_b` fully opaque at t=1 — because a transition frame sits directly
// against a normal frame at each junction and any alpha short of 1 there is
// a visible step. Mirrored exponents (rather than a plain crossfade) keep
// both planes at 67% through the middle instead of 50%, so the frame never
// washes out to near-empty half way through.
const FG_DISSOLVE: f32 = 1.6;
let mut fg_paint = Paint::default();

fg_paint.set_alpha_f(1.0 - t.powf(FG_DISSOLVE));
canvas.draw_image(&img_fg_a, (out_x, out_y), Some(&fg_paint));

fg_paint.set_alpha_f(1.0 - (1.0 - t).powf(FG_DISSOLVE));
canvas.draw_image(&img_fg_b, (in_x, in_y), Some(&fg_paint));

surface_to_pixels(surface, width, height)
}
Expand All @@ -528,6 +546,50 @@ fn render_layer(img: &skia_safe::Image, dest: Rect, width: u32, height: u32) ->
mod camera_pan_tests {
use super::*;

// The junction invariant. A transition frame sits directly against a
// normal frame at each end, so the dissolve must be a no-op exactly there:
// at progress 0 the outgoing scene is untouched, at progress 1 the
// incoming one is. Any alpha short of 1 at an endpoint is a visible step,
// which is the class of bug that produced the halo jumps.
#[test]
fn the_foreground_dissolve_is_a_noop_at_both_junctions() {
let (w, h) = (8u32, 4u32);
let bg = solid(w, h, 0, 0, 0, 255);
let fg_a = solid(w, h, 255, 0, 0, 255);
let fg_b = solid(w, h, 0, 0, 255, 255);

for (progress, expected) in [(0.0, [255u8, 0, 0]), (1.0, [0, 0, 255])] {
let out = camera_pan_transition(
&bg, &bg, &fg_a, &fg_b, w, h, progress, 8.0, 0.0,
&EasingType::Linear, PanBackground::Static,
);
assert_eq!(
&out[0..3], &expected,
"at progress {progress} the adjacent scene must render untouched",
);
}
}

// Mid-pan both planes are partly transparent — that is the effect — but
// neither may collapse to near-nothing or the frame reads as empty.
#[test]
fn mid_pan_both_planes_stay_substantially_visible() {
let (w, h) = (8u32, 4u32);
let bg = solid(w, h, 0, 0, 0, 255);
let fg_a = solid(w, h, 255, 0, 0, 255);
let fg_b = solid(w, h, 0, 0, 255, 255);

let out = camera_pan_transition(
&bg, &bg, &fg_a, &fg_b, w, h, 0.5, 8.0, 0.0,
&EasingType::Linear, PanBackground::Static,
);
// Left half carries the outgoing plane, right half the incoming one.
let left_red = out[0];
let right_blue = out[((w - 1) * 4 + 2) as usize];
assert!(left_red > 128, "outgoing plane faded too far: {left_red}");
assert!(right_blue > 128, "incoming plane still too faint: {right_blue}");
}

fn solid(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> Vec<u8> {
(0..width * height).flat_map(|_| [r, g, b, a]).collect()
}
Expand Down
7 changes: 7 additions & 0 deletions crates/rustmotion-core/src/schema/animation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ pub struct PresetConfig {
/// `AnimationTiming::spring`).
#[serde(default)]
pub spring: Option<SpringConfig>,
/// Travel of an oscillating preset, in pixels (`float_3d`; default 12).
///
/// Varying it across elements in one scene is what turns a shared bob into
/// parallax: things at different depths move by different amounts.
#[serde(default)]
pub amplitude: Option<f64>,
}

impl Default for PresetConfig {
Expand All @@ -180,6 +186,7 @@ impl Default for PresetConfig {
repeat: false,
overshoot: None,
spring: None,
amplitude: None,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/rustmotion-core/src/schema/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ impl AnimationTiming {
/// Convert to PresetConfig for compatibility with resolve_animations.
pub fn to_preset_config(&self) -> PresetConfig {
PresetConfig {
amplitude: None,
delay: self.delay,
duration: self.duration,
repeat: self.repeat,
Expand Down
Loading