diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 93e68bb..d6624fd 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 * (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,214 @@ 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| { + 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) + } + }) + .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 +1197,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)] 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:?}" + ); + } +}