diff --git a/.claude/skills/rustmotion/rules/card-parallax.md b/.claude/skills/rustmotion/rules/card-parallax.md new file mode 100644 index 0000000..015b2fd --- /dev/null +++ b/.claude/skills/rustmotion/rules/card-parallax.md @@ -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. diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 6e06ef4..f1f0780 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -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, diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index 936fdf1..4bc85dd 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -32,6 +32,14 @@ pub struct Counter { pub suffix: Option, #[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, #[serde(flatten)] pub timing: TimingConfig, #[serde(default)] @@ -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, @@ -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, @@ -263,3 +280,67 @@ impl Painter for Counter { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn counter(duration: Option, start_at: Option) -> 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); + } +} diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 4fde267..ecba7cf 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -1380,12 +1380,25 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec 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( @@ -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(), diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 9d59e95..cec5fd0 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -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) } @@ -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 { (0..width * height).flat_map(|_| [r, g, b, a]).collect() } diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs index da5f661..85260ab 100644 --- a/crates/rustmotion-core/src/schema/animation.rs +++ b/crates/rustmotion-core/src/schema/animation.rs @@ -170,6 +170,12 @@ pub struct PresetConfig { /// `AnimationTiming::spring`). #[serde(default)] pub spring: Option, + /// 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, } impl Default for PresetConfig { @@ -180,6 +186,7 @@ impl Default for PresetConfig { repeat: false, overshoot: None, spring: None, + amplitude: None, } } } diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 92b225a..9c4acc0 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -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,