From a5cc054ca4fa6388f865b86c1fe8c25d519cca1d Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 15:55:13 +0200 Subject: [PATCH 1/3] feat(char): add rotate_from, scale and baseline jitter, and reflow The three parts of #363 that were never delivered, all on CharAnimationTiming, following the pattern scale_from and ink_from already set. rotate_from tilts each unit and straightens it to zero by the end of that unit's own animation. rotate_origin picks the pivot: center, or edge for the leading-edge hinge that reads as a staircase. It is excluded from char_rotate_in for the same reason scale_from is excluded from char_scale_in -- that preset already owns the axis. scale_jitter and baseline_jitter give each unit a deterministic size and baseline offset that decays to zero, so a line is born uneven and settles. They are independent of jitter, which only perturbs timing, and the three draw from the same hash under different salts so they do not move in lockstep for one seed. reflow changes what a unit contributes to its line's width rather than what it paints: a unit that has not started contributes nothing, and a starting unit's contribution eases in under reflow_easing. A centred line therefore stays centred while it is written, instead of reserving its final width from the first frame. The complaint that a typewriter grows from a fixed left edge does not reproduce in the real pipeline. text.rs takes align_width from taffy's own layout.width, which is stable, so centred growth is already symmetric; the asymmetry exists only in a non-finite-width fallback branch that taffy never produces. Refs #429 --- crates/rustmotion-components/src/intrinsic.rs | 290 +++++++++++++- crates/rustmotion-components/src/text.rs | 366 ++++++++++++++++++ crates/rustmotion-core/src/engine/animator.rs | 82 +++- crates/rustmotion-core/src/schema/video.rs | 66 ++++ 4 files changed, 781 insertions(+), 23 deletions(-) diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 93e68bb..65240d5 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -733,22 +733,17 @@ impl IntrinsicMeasure for RichTextIntrinsic { } } -use rustmotion_core::engine::animator::{ease, ResolvedCharAnimation}; +use rustmotion_core::engine::animator::ResolvedCharAnimation; use rustmotion_core::engine::renderer::{draw_text_with_fallback, paint_from_hex}; -use rustmotion_core::schema::{CharAnimPreset, TextAlign, TextAnimGranularity}; +use rustmotion_core::schema::{CharAnimPreset, RotateOrigin, TextAlign, TextAnimGranularity}; use skia_safe::{Canvas, Paint}; pub fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 { - let unit_start = cfg.unit_start(idx); - let unit_end = unit_start + cfg.duration as f64; - let raw_t = if time <= unit_start { - 0.0 - } else if time >= unit_end { - 1.0 - } else { - (time - unit_start) / (unit_end - unit_start) - }; - ease(raw_t, &cfg.easing) as f32 + cfg.unit_progress(idx, time) +} + +pub fn reflow_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 { + cfg.reflow_progress(idx, time) } fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option { @@ -799,6 +794,30 @@ pub fn apply_text_anim_preset( canvas.translate((-center_x, -center_y)); } } + if let Some(from_deg) = cfg.rotate_from { + if !matches!(preset, CharAnimPreset::RotateIn) { + let angle = from_deg as f32 * (1.0 - t.clamp(0.0, 1.0)); + let (px, py) = match cfg.rotate_origin { + RotateOrigin::Center => (center_x, center_y), + RotateOrigin::Edge => (cursor_x, center_y), + }; + canvas.translate((px, py)); + canvas.rotate(angle, None); + canvas.translate((-px, -py)); + } + } + let scale_jitter_delta = cfg.scale_jitter_delta(unit_idx); + if scale_jitter_delta.abs() > 1e-6 { + let js = 1.0 + scale_jitter_delta * (1.0 - t.clamp(0.0, 1.0)); + canvas.translate((center_x, center_y)); + canvas.scale((js, js)); + canvas.translate((-center_x, -center_y)); + } + let baseline_jitter_delta = cfg.baseline_jitter_delta(unit_idx); + if baseline_jitter_delta.abs() > 1e-6 { + let jy = baseline_jitter_delta * font_size * (1.0 - t.clamp(0.0, 1.0)); + canvas.translate((0.0, jy)); + } match preset { CharAnimPreset::ScaleIn => { @@ -948,6 +967,215 @@ pub fn apply_text_anim_preset( } #[allow(clippy::too_many_arguments)] +struct ReflowWordToken { + text: String, + is_space: bool, + full_width: f32, +} + +fn tokenize_reflow_words( + line: &str, + font: &Font, + emoji_font: &Option, + letter_spacing: f32, +) -> Vec { + let mut toks = Vec::new(); + let mut chars = line.chars().peekable(); + while chars.peek().is_some() { + let mut spaces = String::new(); + while let Some(&c) = chars.peek() { + if c.is_whitespace() { + spaces.push(c); + chars.next(); + } else { + break; + } + } + if !spaces.is_empty() { + let full_width = measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing); + toks.push(ReflowWordToken { + text: spaces, + is_space: true, + full_width, + }); + } + let mut word = String::new(); + while let Some(&c) = chars.peek() { + if c.is_whitespace() { + break; + } + word.push(c); + chars.next(); + } + if !word.is_empty() { + let full_width = measure_text_with_fallback(&word, font, emoji_font, letter_spacing); + toks.push(ReflowWordToken { + text: word, + is_space: false, + full_width, + }); + } + } + toks +} + +fn reflowed_word_width( + tok: &ReflowWordToken, + idx: usize, + char_anim: &ResolvedCharAnimation, + time: f64, +) -> f32 { + if tok.is_space { + tok.full_width + } else { + tok.full_width * reflow_progress(char_anim, idx, time) + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_reflowing_word_line( + canvas: &Canvas, + font: &Font, + emoji_font: &Option, + paint: &Paint, + letter_spacing: f32, + align: &TextAlign, + align_width: f32, + line_y: f32, + line: &str, + char_anim: &ResolvedCharAnimation, + time: f64, + base_idx: usize, +) -> usize { + let toks = tokenize_reflow_words(line, font, emoji_font, letter_spacing); + + let mut word_counter = 0usize; + let effective_width: f32 = toks + .iter() + .map(|tok| { + let width = if tok.is_space { + tok.full_width + } else { + let idx = base_idx + word_counter; + word_counter += 1; + reflowed_word_width(tok, idx, char_anim, time) + }; + width + }) + .sum(); + + let line_x = match align { + TextAlign::Left => 0.0, + TextAlign::Center => (align_width - effective_width) / 2.0, + TextAlign::Right => align_width - effective_width, + }; + + let mut cursor_x = line_x; + word_counter = 0; + for tok in &toks { + if tok.is_space { + draw_text_with_fallback( + canvas, &tok.text, font, emoji_font, 0.0, cursor_x, line_y, paint, + ); + cursor_x += tok.full_width; + continue; + } + let idx = base_idx + word_counter; + word_counter += 1; + let t = unit_progress(char_anim, idx, time); + let draw_width = reflowed_word_width(tok, idx, char_anim, time); + + canvas.save(); + apply_text_anim_preset( + canvas, + &tok.text, + font, + emoji_font, + paint, + cursor_x, + line_y, + draw_width, + letter_spacing, + char_anim, + t, + time, + idx, + font.size(), + ); + canvas.restore(); + + cursor_x += draw_width; + } + base_idx + word_counter +} + +#[allow(clippy::too_many_arguments)] +fn paint_reflowing_char_line( + canvas: &Canvas, + font: &Font, + emoji_font: &Option, + paint: &Paint, + letter_spacing: f32, + align: &TextAlign, + align_width: f32, + line_y: f32, + line: &str, + char_anim: &ResolvedCharAnimation, + time: f64, + base_idx: usize, +) -> usize { + let chars_list: Vec = line.chars().collect(); + let widths: Vec = chars_list + .iter() + .map(|ch| { + let (w, _) = font.measure_str(ch.to_string(), None); + w + letter_spacing + }) + .collect(); + + let effective_width: f32 = widths + .iter() + .enumerate() + .map(|(i, w)| w * reflow_progress(char_anim, base_idx + i, time)) + .sum(); + + let line_x = match align { + TextAlign::Left => 0.0, + TextAlign::Center => (align_width - effective_width) / 2.0, + TextAlign::Right => align_width - effective_width, + }; + + let mut cursor_x = line_x; + for (i, ch) in chars_list.iter().enumerate() { + let idx = base_idx + i; + let ch_str = ch.to_string(); + let t = unit_progress(char_anim, idx, time); + let draw_width = widths[i] * reflow_progress(char_anim, idx, time); + + canvas.save(); + apply_text_anim_preset( + canvas, + &ch_str, + font, + emoji_font, + paint, + cursor_x, + line_y, + draw_width, + 0.0, + char_anim, + t, + time, + idx, + font.size(), + ); + canvas.restore(); + + cursor_x += draw_width; + } + base_idx + chars_list.len() +} + pub fn render_char_animation( canvas: &Canvas, font: &Font, @@ -970,13 +1198,49 @@ pub fn render_char_animation( continue; } + let line_y = line_idx as f32 * line_height_val + baseline_offset; + + if char_anim.reflow { + global_unit_idx = if is_word_mode { + paint_reflowing_word_line( + canvas, + font, + emoji_font, + paint, + letter_spacing, + &align, + align_width, + line_y, + line, + char_anim, + time, + global_unit_idx, + ) + } else { + paint_reflowing_char_line( + canvas, + font, + emoji_font, + paint, + letter_spacing, + &align, + align_width, + line_y, + line, + char_anim, + time, + global_unit_idx, + ) + }; + continue; + } + let advance_width = measure_text_with_fallback(line, font, emoji_font, letter_spacing); let line_x = match align { TextAlign::Left => 0.0, TextAlign::Center => (align_width - advance_width) / 2.0, TextAlign::Right => align_width - advance_width, }; - let line_y = line_idx as f32 * line_height_val + baseline_offset; if is_word_mode { let mut cursor_x = line_x; diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index eaa8dd3..1aa2e36 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -2161,4 +2161,370 @@ mod tests { animated field defaults to NaN precisely so absent means absent" ); } + + fn render_pipeline( + component_json: serde_json::Value, + box_x: f32, + box_y: f32, + w: i32, + h: i32, + time: f64, + scene_duration: f64, + ) -> Vec { + use crate::box_builder::{build_scene_with_anim, BuildAnimationCtx}; + use crate::legacy_dispatch::LegacyPaintDispatcher; + use crate::{ChildComponent, Component, PositionMode}; + use rustmotion_core::css::taffy_bridge::ConversionContext; + use rustmotion_core::engine::layout_pass::run_layout; + use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; + + let component: Component = serde_json::from_value(component_json).expect("deserialize"); + let child = ChildComponent { + id: None, + component, + position: Some(PositionMode::Absolute { x: box_x, y: box_y }), + x: None, + y: None, + z_index: None, + bleed: false, + }; + let children = vec![child]; + + let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); + + let built = build_scene_with_anim( + &children, + (w as f32, h as f32), + BuildAnimationCtx { + time, + scenario_time: time, + scene_duration, + fps: 30, + }, + ); + let layout = run_layout( + &built.root, + (w as f32, h as f32), + &ConversionContext::default(), + ); + let dispatcher = LegacyPaintDispatcher::for_scene(&built); + let frame = PaintFrame { + light: Default::default(), + time, + scenario_time: time, + frame_index: (time * 30.0) as u32, + fps: 30, + video_width: w as u32, + video_height: h as u32, + scene_duration, + camera: None, + }; + paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); + + let row_bytes = w as usize * 4; + let mut pixels = vec![0u8; row_bytes * h as usize]; + let info = skia_safe::ImageInfo::new( + (w, h), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Premul, + None, + ); + surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); + pixels + } + + fn ink_x_range(pixels: &[u8], w: i32, h: i32) -> Option<(i32, i32)> { + let mut lo = None; + let mut hi = None; + for y in 0..h { + for x in 0..w { + let idx = ((y * w + x) * 4 + 3) as usize; + if pixels[idx] > 0 { + lo = Some(lo.map_or(x, |m: i32| m.min(x))); + hi = Some(hi.map_or(x, |m: i32| m.max(x))); + } + } + } + lo.zip(hi) + } + + #[test] + fn rotate_from_tilts_a_unit_and_widens_its_footprint_mid_animation() { + const W: i32 = 400; + const H: i32 = 220; + + let json_for = |rotate_from: Option| { + let mut anim = serde_json::json!({ + "name": "char_fade_in", + "granularity": "char", + "duration": 1.0, + "stagger": 0.0, + }); + if let Some(r) = rotate_from { + anim["rotate_from"] = serde_json::json!(r); + } + serde_json::json!({ + "type": "text", + "content": "I", + "style": { + "width": 300, "height": 200, + "font-size": 120, + "color": "#ffffff", + "white-space": "nowrap", + "animation": [anim] + } + }) + }; + + let plain = render_pipeline(json_for(None), 40.0, 10.0, W, H, 0.5, 1.0); + let tilted = render_pipeline(json_for(Some(80.0)), 40.0, 10.0, W, H, 0.5, 1.0); + + let (plain_lo, plain_hi) = ink_x_range(&plain, W, H).expect("plain glyph paints ink"); + let (tilted_lo, tilted_hi) = ink_x_range(&tilted, W, H).expect("tilted glyph paints ink"); + + let plain_width = plain_hi - plain_lo; + let tilted_width = tilted_hi - tilted_lo; + + assert!( + tilted_width > plain_width + 20, + "rotate_from must visibly tilt the unit mid-animation, widening its horizontal \ + footprint: plain width={plain_width}px, tilted width={tilted_width}px" + ); + } + + #[test] + fn rotate_from_has_settled_to_upright_by_the_end_of_the_units_own_animation() { + const W: i32 = 400; + const H: i32 = 220; + + let json_for = |rotate_from: Option| { + let mut anim = serde_json::json!({ + "name": "char_fade_in", + "granularity": "char", + "duration": 1.0, + "stagger": 0.0, + }); + if let Some(r) = rotate_from { + anim["rotate_from"] = serde_json::json!(r); + } + serde_json::json!({ + "type": "text", + "content": "I", + "style": { + "width": 300, "height": 200, + "font-size": 120, + "color": "#ffffff", + "white-space": "nowrap", + "animation": [anim] + } + }) + }; + + let plain = render_pipeline(json_for(None), 40.0, 10.0, W, H, 1.0, 1.0); + let settled = render_pipeline(json_for(Some(80.0)), 40.0, 10.0, W, H, 1.0, 1.0); + + let plain_range = ink_x_range(&plain, W, H).expect("plain glyph paints ink"); + let settled_range = ink_x_range(&settled, W, H).expect("settled glyph paints ink"); + + assert_eq!( + plain_range, settled_range, + "rotate_from must straighten to upright by the end of the unit's own animation, \ + landing at the same horizontal footprint as no rotation at all" + ); + } + + #[test] + fn scale_jitter_perturbs_a_units_born_size_and_settles_away_by_the_end() { + const W: i32 = 300; + const H: i32 = 200; + + let json_for = |scale_jitter: Option| { + let mut anim = serde_json::json!({ + "name": "char_fade_in", + "granularity": "char", + "duration": 1.0, + "stagger": 0.0, + "seed": 7, + }); + if let Some(s) = scale_jitter { + anim["scale_jitter"] = serde_json::json!(s); + } + serde_json::json!({ + "type": "text", + "content": "O", + "style": { + "width": 200, "height": 150, + "font-size": 100, + "color": "#ffffff", + "white-space": "nowrap", + "animation": [anim] + } + }) + }; + + let plain_mid = render_pipeline(json_for(None), 30.0, 20.0, W, H, 0.5, 1.0); + let jittered_mid = render_pipeline(json_for(Some(0.6)), 30.0, 20.0, W, H, 0.5, 1.0); + assert_ne!( + plain_mid, jittered_mid, + "scale_jitter must visibly perturb a unit's size mid-animation" + ); + + let plain_end = render_pipeline(json_for(None), 30.0, 20.0, W, H, 1.0, 1.0); + let jittered_end = render_pipeline(json_for(Some(0.6)), 30.0, 20.0, W, H, 1.0, 1.0); + let plain_range = ink_x_range(&plain_end, W, H).expect("plain glyph paints ink"); + let jittered_range = ink_x_range(&jittered_end, W, H).expect("jittered glyph paints ink"); + assert_eq!( + plain_range, jittered_range, + "scale_jitter must settle away by the end of the unit's own animation, landing at \ + the same footprint as no jitter at all — letters born at different sizes, then \ + aligning" + ); + } + + #[test] + fn baseline_jitter_perturbs_a_units_born_baseline_and_settles_away_by_the_end() { + const W: i32 = 300; + const H: i32 = 200; + + let json_for = |baseline_jitter: Option| { + let mut anim = serde_json::json!({ + "name": "char_fade_in", + "granularity": "char", + "duration": 1.0, + "stagger": 0.0, + "seed": 11, + }); + if let Some(b) = baseline_jitter { + anim["baseline_jitter"] = serde_json::json!(b); + } + serde_json::json!({ + "type": "text", + "content": "O", + "style": { + "width": 200, "height": 150, + "font-size": 100, + "color": "#ffffff", + "white-space": "nowrap", + "animation": [anim] + } + }) + }; + + let plain_mid = render_pipeline(json_for(None), 30.0, 20.0, W, H, 0.5, 1.0); + let jittered_mid = render_pipeline(json_for(Some(0.3)), 30.0, 20.0, W, H, 0.5, 1.0); + assert_ne!( + plain_mid, jittered_mid, + "baseline_jitter must visibly perturb a unit's baseline mid-animation" + ); + + let plain_end = render_pipeline(json_for(None), 30.0, 20.0, W, H, 1.0, 1.0); + let jittered_end = render_pipeline(json_for(Some(0.3)), 30.0, 20.0, W, H, 1.0, 1.0); + assert_eq!( + plain_end, jittered_end, + "baseline_jitter must settle away by the end of the unit's own animation, landing \ + on the same laid-out baseline as no jitter at all" + ); + } + + #[test] + fn reflow_keeps_a_centered_line_centred_while_it_is_being_written() { + const W: i32 = 900; + const H: i32 = 200; + const BOX_X: f32 = 0.0; + const BOX_W: f32 = 700.0; + let box_center = BOX_X + BOX_W / 2.0; + + let json_for = |reflow: bool| { + serde_json::json!({ + "type": "text", + "content": "reflowing centered text", + "style": { + "width": BOX_W, "height": 100, + "font-size": 48, + "color": "#ffffff", + "text-align": "center", + "white-space": "nowrap", + "animation": [{ + "name": "char_fade_in", + "granularity": "char", + "duration": 0.05, + "stagger": 0.05, + "reflow": reflow, + }] + } + }) + }; + + let sample_time = 0.5; + let no_reflow = render_pipeline(json_for(false), BOX_X, 40.0, W, H, sample_time, 2.0); + let reflowed = render_pipeline(json_for(true), BOX_X, 40.0, W, H, sample_time, 2.0); + + let (lo_a, hi_a) = + ink_x_range(&no_reflow, W, H).expect("no-reflow must paint some ink by this time"); + let (lo_b, hi_b) = + ink_x_range(&reflowed, W, H).expect("reflow must paint some ink by this time"); + + let center_no_reflow = (lo_a + hi_a) as f32 / 2.0; + let center_reflow = (lo_b + hi_b) as f32 / 2.0; + + assert!( + (center_reflow - box_center).abs() < 20.0, + "reflow must keep the growing centred line's ink centred on the box: box \ + center={box_center}, got ink center={center_reflow}" + ); + assert!( + center_no_reflow < box_center - 40.0, + "control: without reflow, a partially revealed centred line must still read left \ + of the box center, since every unit reserves its final (fully revealed) position \ + from frame one and only the early units have appeared yet — box \ + center={box_center}, got ink center={center_no_reflow}" + ); + } + + #[test] + fn rotate_from_also_reaches_gradient_text_through_the_shared_render_path() { + const W: i32 = 400; + const H: i32 = 220; + + let json_for = |rotate_from: Option| { + let mut anim = serde_json::json!({ + "name": "char_fade_in", + "granularity": "char", + "duration": 1.0, + "stagger": 0.0, + }); + if let Some(r) = rotate_from { + anim["rotate_from"] = serde_json::json!(r); + } + serde_json::json!({ + "type": "gradient_text", + "content": "I", + "colors": ["#FF4FB0", "#4B5BFF"], + "style": { + "width": 300, "height": 200, + "font-size": 120, + "white-space": "nowrap", + "animation": [anim] + } + }) + }; + + let plain = render_pipeline(json_for(None), 40.0, 10.0, W, H, 0.5, 1.0); + let tilted = render_pipeline(json_for(Some(80.0)), 40.0, 10.0, W, H, 0.5, 1.0); + + let (plain_lo, plain_hi) = ink_x_range(&plain, W, H).expect("plain glyph paints ink"); + let (tilted_lo, tilted_hi) = ink_x_range(&tilted, W, H).expect("tilted glyph paints ink"); + + let plain_width = plain_hi - plain_lo; + let tilted_width = tilted_hi - tilted_lo; + + assert!( + tilted_width > plain_width + 20, + "rotate_from must also tilt a gradient_text unit through the shared \ + intrinsic::render_char_animation path, without any change to gradient_text.rs \ + itself: plain width={plain_width}px, tilted width={tilted_width}px" + ); + } } diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index f257a6c..ceabf03 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -1,7 +1,7 @@ use crate::schema::{ Animation, AnimationEffect, AnimationPreset, BurstConfig, CharAnimPreset, EasingType, - GlowConfig, Keyframe, KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, SpringConfig, - TextAnimDirection, TextAnimGranularity, WiggleConfig, + GlowConfig, Keyframe, KeyframeValue, MotionPathConfig, OrbitConfig, PresetConfig, RotateOrigin, + SpringConfig, TextAnimDirection, TextAnimGranularity, WiggleConfig, }; pub const DEFAULT_CHAR_BLUR_SIGMA: f32 = 14.0; @@ -40,24 +40,74 @@ pub struct ResolvedCharAnimation { pub jitter: f32, pub seed: u32, pub ink_from: Option, + pub rotate_from: Option, + pub rotate_origin: RotateOrigin, + pub scale_jitter: f32, + pub baseline_jitter: f32, + pub reflow: bool, + pub reflow_easing: EasingType, } +fn unit_rand_signed(idx: usize, seed: u32, salt: u64) -> f64 { + let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (seed as u64) ^ salt; + h ^= h >> 30; + h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9); + h ^= h >> 27; + h = h.wrapping_mul(0x94D0_49BB_1331_11EB); + h ^= h >> 31; + let unit = (h >> 11) as f64 / (1u64 << 53) as f64; + unit * 2.0 - 1.0 +} + +const BASELINE_JITTER_SALT: u64 = 0xB522_9E17_C2A1_0D4F; +const SCALE_JITTER_SALT: u64 = 0x5A1E_3B7C_9F02_88D1; + impl ResolvedCharAnimation { pub fn unit_start(&self, idx: usize) -> f64 { let even = self.delay as f64 + idx as f64 * self.stagger as f64; if self.jitter.abs() < 1e-6 || self.stagger.abs() < 1e-6 { return even; } - let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (self.seed as u64); - h ^= h >> 30; - h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9); - h ^= h >> 27; - h = h.wrapping_mul(0x94D0_49BB_1331_11EB); - h ^= h >> 31; - let unit = (h >> 11) as f64 / (1u64 << 53) as f64; - let nudge = (unit * 2.0 - 1.0) * self.jitter as f64 * self.stagger as f64; + let nudge = unit_rand_signed(idx, self.seed, 0) * self.jitter as f64 * self.stagger as f64; (even + nudge).max(self.delay as f64) } + + fn raw_unit_progress(&self, idx: usize, time: f64) -> f32 { + let unit_start = self.unit_start(idx); + let unit_end = unit_start + self.duration as f64; + if time <= unit_start { + 0.0 + } else if time >= unit_end { + 1.0 + } else { + ((time - unit_start) / (unit_end - unit_start)) as f32 + } + } + + pub fn unit_progress(&self, idx: usize, time: f64) -> f32 { + ease(self.raw_unit_progress(idx, time) as f64, &self.easing) as f32 + } + + pub fn reflow_progress(&self, idx: usize, time: f64) -> f32 { + ease( + self.raw_unit_progress(idx, time) as f64, + &self.reflow_easing, + ) as f32 + } + + pub fn scale_jitter_delta(&self, idx: usize) -> f32 { + if self.scale_jitter.abs() < 1e-6 { + return 0.0; + } + (unit_rand_signed(idx, self.seed, SCALE_JITTER_SALT) as f32) * self.scale_jitter + } + + pub fn baseline_jitter_delta(&self, idx: usize) -> f32 { + if self.baseline_jitter.abs() < 1e-6 { + return 0.0; + } + (unit_rand_signed(idx, self.seed, BASELINE_JITTER_SALT) as f32) * self.baseline_jitter + } } pub struct ExtractedEffects<'a> { @@ -129,6 +179,12 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { jitter: t.jitter.unwrap_or(0.0) as f32, seed: t.seed.unwrap_or(0), ink_from: t.ink_from.clone(), + rotate_from: t.rotate_from.map(|r| r as f32), + rotate_origin: t.rotate_origin, + scale_jitter: t.scale_jitter.unwrap_or(0.0) as f32, + baseline_jitter: t.baseline_jitter.unwrap_or(0.0) as f32, + reflow: t.reflow, + reflow_easing: t.reflow_easing.clone(), }); } AnimationEffect::Glow(config) => { @@ -3019,6 +3075,12 @@ mod char_animation_tuning_tests { jitter, seed, ink_from: None, + rotate_from: None, + rotate_origin: RotateOrigin::default(), + scale_jitter: 0.0, + baseline_jitter: 0.0, + reflow: false, + reflow_easing: EasingType::default(), } } diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 667a3db..956fc91 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -465,6 +465,50 @@ pub struct CharAnimationTiming { /// streamed tokens read as unsettled before the eye accepts them. #[serde(default)] pub ink_from: Option, + /// Tilt (degrees) each unit starts at, straightening to 0 by the end of + /// its own animation — the "staircase word reveal" where every letter + /// settles from a lean. Composes with the preset instead of replacing + /// it, the same rule `scale_from` follows; `char_rotate_in` already + /// owns a rotation curve and ignores this. Unset means no tilt (the + /// historical behaviour). + #[serde(default)] + pub rotate_from: Option, + /// Pivot `rotate_from` turns each unit around. Only meaningful + /// alongside `rotate_from`. + #[serde(default)] + pub rotate_origin: RotateOrigin, + /// Deterministic per-unit size noise: each unit is additionally born at + /// `1.0 ± scale_jitter` and converges to its own normal scale by the + /// end of its animation, on top of whatever the preset already does — + /// "letters born at different sizes, then aligning". Derived from + /// `seed` and the unit's index, like `jitter`; unset means no size + /// noise (the historical behaviour). + #[serde(default)] + pub scale_jitter: Option, + /// Deterministic per-unit baseline noise, as a fraction of `font-size`: + /// each unit is additionally born offset by up to + /// `±baseline_jitter × font-size` from its laid-out baseline and settles + /// onto it by the end of its animation. Derived from `seed` and the + /// unit's index, like `jitter`; unset means no baseline noise (the + /// historical behaviour). + #[serde(default)] + pub baseline_jitter: Option, + /// When true, a unit that hasn't started its own animation yet + /// contributes no width to its line, and one that's mid-animation + /// contributes a share eased in by `reflow_easing` — so the line grows + /// as units arrive instead of reserving its final width from the first + /// frame. Combined with `text-align: center`, this is what keeps a + /// centred line centred while it's being written instead of growing + /// from a fixed left edge. Default `false` keeps the historical + /// behaviour: every unit's slot is reserved from frame one, only its + /// paint (opacity/position/scale/blur) animates. + #[serde(default)] + pub reflow: bool, + /// Easing curve for the width a unit contributes to its line while + /// `reflow` is on — independent from `easing`, which times the unit's + /// own visual entrance. Only meaningful alongside `reflow`. + #[serde(default)] + pub reflow_easing: EasingType, } impl Default for CharAnimationTiming { @@ -483,6 +527,12 @@ impl Default for CharAnimationTiming { jitter: None, seed: None, ink_from: None, + rotate_from: None, + rotate_origin: RotateOrigin::default(), + scale_jitter: None, + baseline_jitter: None, + reflow: false, + reflow_easing: EasingType::default(), } } } @@ -565,6 +615,22 @@ impl TextAnimDirection { } } +/// Pivot a `rotate_from` tilt turns around. Only meaningful alongside +/// `rotate_from`; ignored otherwise. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum RotateOrigin { + /// The unit's own visual centre — the same pivot `char_scale_in`, + /// `char_bounce` and `char_rotate_in` already turn around (default). + #[default] + Center, + /// The unit's leading edge, at the text baseline — where the previous + /// unit ends and this one begins. Turning around this point instead of + /// the centre reads as each unit hinging open from where it's typed, + /// the "staircase word reveal" look. + Edge, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "snake_case")] #[derive(PartialEq)] From 46352dbcc91332eb74fa629e5d3a9ee827f8c074 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 16:24:32 +0200 Subject: [PATCH 2/3] fix(char): satisfy clippy on the reflow width fold and the tilt cast from_deg is already f32, so the cast was a no-op; the width binding in the fold was returned immediately after being named. Both are lints CI runs with -D warnings and a scoped -p run does not surface. --- crates/rustmotion-components/src/intrinsic.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 65240d5..d6624fd 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -796,7 +796,7 @@ pub fn apply_text_anim_preset( } if let Some(from_deg) = cfg.rotate_from { if !matches!(preset, CharAnimPreset::RotateIn) { - let angle = from_deg as f32 * (1.0 - t.clamp(0.0, 1.0)); + let angle = from_deg * (1.0 - t.clamp(0.0, 1.0)); let (px, py) = match cfg.rotate_origin { RotateOrigin::Center => (center_x, center_y), RotateOrigin::Edge => (cursor_x, center_y), @@ -1053,14 +1053,13 @@ fn paint_reflowing_word_line( let effective_width: f32 = toks .iter() .map(|tok| { - let width = if tok.is_space { + if tok.is_space { tok.full_width } else { let idx = base_idx + word_counter; word_counter += 1; reflowed_word_width(tok, idx, char_anim, time) - }; - width + } }) .sum(); From 1b58c82e0f533c415c841c960ba1713ac42cf588 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Tue, 29 Sep 2026 16:28:47 +0200 Subject: [PATCH 3/3] fix(validate): say that reflow does not reach rich_text reflow lives on the shared CharAnimationTiming, so the schema takes it on a rich_text and nothing happens: that component's char animation lays out from token positions computed once, together with the pill backgrounds, so a unit that has not started still holds its slot. A field the validator accepts must have an effect or say why it does not. Implementing it there means re-deriving token offsets and pill bounds from reflowed cumulative widths, which is a different piece of work from the shared path; until someone does it, the warning names the component and points at text and gradient_text. char-animation-tuning.md documents the three new knobs and this gap. --- .../skills/rules/char-animation-tuning.md | 12 +++ .../src/cli/commands/validate_schema.rs | 96 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/crates/rustmotion/skills/rules/char-animation-tuning.md b/crates/rustmotion/skills/rules/char-animation-tuning.md index 6df4765..17e44a8 100644 --- a/crates/rustmotion/skills/rules/char-animation-tuning.md +++ b/crates/rustmotion/skills/rules/char-animation-tuning.md @@ -46,3 +46,15 @@ The seven `char_*` presets (`char_scale_in`, `char_fade_in`, `char_wave`, `char_ ## A note on `char_blur_in` It goes through the same resolution path as its six siblings: it **inherits** `stagger` from a parent container and works inside a `timeline` step. (That wasn't always the case.) + +## `rotate_from`, les deux jitters, et `reflow` + +| Champ | Rôle | Défaut | +|---|---|---| +| `rotate_from` (+ `rotate_origin`) | Inclinaison de départ en degrés, redressée à 0 à la fin de l'animation de l'unité. `rotate_origin: "center"` (défaut) ou `"edge"` — charnière sur le bord d'attaque, c'est ce qui donne l'escalier. Inerte sur `char_rotate_in`, qui possède déjà cet axe. | absent | +| `scale_jitter` / `baseline_jitter` (+ `seed`) | Bruit déterministe par unité sur la taille et la ligne de base, qui retombe à 0 à la fin. Indépendant de `jitter`, qui ne décale que le timing — les trois tirent du même hash sous des sels différents, donc ils ne bougent pas en bloc pour une même graine. | absent | +| `reflow` (+ `reflow_easing`) | Une unité pas encore démarrée ne contribue aucune largeur à sa ligne, et la contribution d'une unité qui démarre entre via `reflow_easing`. Une ligne centrée reste donc centrée pendant qu'elle s'écrit, au lieu de réserver sa largeur finale dès la première frame. | `false` | + +`reflow` modifie **ce qu'une unité occupe**, pas ce qu'elle peint : les glyphes se dessinent à pleine taille, seule leur position dans la ligne s'anime. Un glyphe peut donc chevaucher brièvement son voisin pendant la transition — c'est le prix à payer pour que chaque preset garde son propre visuel intact. + +> `reflow` **n'a aucun effet sur `rich_text`**, dont le rendu d'animation par caractère calcule ses positions de tokens et ses fonds de pastille en une passe, avant l'animation. Le validateur le signale plutôt que de laisser le champ paraître actif. Pour une ligne centrée qui doit rester centrée pendant qu'elle s'écrit, c'est `text` ou `gradient_text`. diff --git a/crates/rustmotion/src/cli/commands/validate_schema.rs b/crates/rustmotion/src/cli/commands/validate_schema.rs index 57a7f85..81ebdfe 100644 --- a/crates/rustmotion/src/cli/commands/validate_schema.rs +++ b/crates/rustmotion/src/cli/commands/validate_schema.rs @@ -170,6 +170,8 @@ fn validate_children( if let AnimationEffect::MotionPath(cfg) = effect { check_motion_path_config(cfg, &p, errors, warnings); } + + check_reflow_reaches_this_component(&child.component, effect, &p, warnings); } } @@ -800,6 +802,38 @@ fn check_transition_ignored_fields( )); } +fn char_timing(effect: &AnimationEffect) -> Option<&CharAnimationTiming> { + match effect { + AnimationEffect::CharScaleIn(t) + | AnimationEffect::CharFadeIn(t) + | AnimationEffect::CharWave(t) + | AnimationEffect::CharBounce(t) + | AnimationEffect::CharRotateIn(t) + | AnimationEffect::CharSlideUp(t) + | AnimationEffect::CharBlurIn(t) => Some(t), + _ => None, + } +} + +fn check_reflow_reaches_this_component( + component: &Component, + effect: &AnimationEffect, + path: &str, + warnings: &mut Vec, +) { + if !matches!(component, Component::RichText(_)) { + return; + } + if char_timing(effect).is_some_and(|t| t.reflow) { + warnings.push(format!( + "{path}: reflow has no effect on rich_text. Its char animation lays out from token \ + positions computed once, together with the pill backgrounds, so a unit that has \ + not started still holds its slot. Use text or gradient_text where a centred line \ + has to stay centred while it is written." + )); + } +} + fn check_spring_config(spring: &SpringConfig, path: &str, errors: &mut Vec) { if spring.mass <= 0.0 { errors.push(format!( @@ -2189,3 +2223,65 @@ mod mask_transition_message_tests { ); } } + +#[cfg(test)] +mod reflow_reach_tests { + use super::*; + + fn warnings_for(component: serde_json::Value) -> Vec { + let child: ChildComponent = serde_json::from_value(component).expect("component"); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + validate_children(&[child], "test", 6.0, &mut errors, &mut warnings); + warnings + } + + fn with_reflow(kind: &str) -> serde_json::Value { + let mut v = serde_json::json!({ + "type": kind, + "style": { "animation": [ + { "name": "char_fade_in", "duration": 0.8, "reflow": true } + ]} + }); + if kind == "rich_text" { + v["spans"] = serde_json::json!([{ "text": "Rustmotion" }]); + } else { + v["content"] = serde_json::json!("Rustmotion"); + } + v + } + + #[test] + fn reflow_on_rich_text_is_named_instead_of_being_accepted_and_inert() { + let warnings = warnings_for(with_reflow("rich_text")); + assert!( + warnings + .iter() + .any(|w| w.contains("no effect on rich_text")), + "rich_text lays out from token positions computed once, so reflow cannot move \ + them — a field the validator accepts must either work or say so: {warnings:?}" + ); + } + + #[test] + fn reflow_on_text_is_left_alone_because_it_works_there() { + let warnings = warnings_for(with_reflow("text")); + assert!( + !warnings.iter().any(|w| w.contains("no effect")), + "got {warnings:?}" + ); + } + + #[test] + fn a_rich_text_char_animation_without_reflow_is_not_warned_about() { + let warnings = warnings_for(serde_json::json!({ + "type": "rich_text", + "spans": [{ "text": "Rustmotion" }], + "style": { "animation": [{ "name": "char_fade_in", "duration": 0.8 }] } + })); + assert!( + !warnings.iter().any(|w| w.contains("no effect")), + "got {warnings:?}" + ); + } +}