From eee12c629737aa1e4aa03bfed14ac64f1815c175 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 1 Aug 2026 19:11:34 +0200 Subject: [PATCH 01/10] feat(text): add a blur-based word reveal preset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reference films reveal a line word by word, each word arriving blurred and settling sharp. The per-unit machinery already existed — granularity, stagger, delay, easing, overshoot — but none of the six char presets blurred, so the one detail that reads as expensive was the one thing not expressible. char_blur_in drives blur, opacity and a slight upward drift from a single eased progress, so a word is one continuous animation rather than three stacked effects. Sigma is configurable and defaults to 14px, chosen because at 150px type it reads as unmistakably blurred early while letterforms stay ghost-legible. Measured on the rendered frames: Laplacian variance over a word's bounding box climbs from 1.29 mid-reveal to 3712 once settled, converging exactly at that word's unit_end and staying flat after. Known limitation, documented in the code: the preset is resolved directly from style.animation rather than through extract_effects, so unlike its five siblings it does not pick up container-level stagger shifting or timeline-step copies. Refs #118 --- .../src/commands/validate_schema.rs | 3 +- crates/rustmotion-components/src/text.rs | 319 +++++++++++++++++- crates/rustmotion-core/src/schema/video.rs | 32 +- 3 files changed, 350 insertions(+), 4 deletions(-) diff --git a/crates/rustmotion-cli/src/commands/validate_schema.rs b/crates/rustmotion-cli/src/commands/validate_schema.rs index 29bdfb5..5b116b1 100644 --- a/crates/rustmotion-cli/src/commands/validate_schema.rs +++ b/crates/rustmotion-cli/src/commands/validate_schema.rs @@ -421,7 +421,8 @@ fn entrance_budget(effect: &AnimationEffect) -> Option<(f64, f64)> { | AnimationEffect::CharWave(t) | AnimationEffect::CharBounce(t) | AnimationEffect::CharRotateIn(t) - | AnimationEffect::CharSlideUp(t) => char_budget(t), + | AnimationEffect::CharSlideUp(t) + | AnimationEffect::CharBlurIn(t) => char_budget(t), // Custom keyframes AnimationEffect::Keyframes(k) => { diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 9534f16..e29d5b7 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -17,11 +17,19 @@ use rustmotion_core::engine::renderer::{ typeface_with_fallback, wrap_text_with_fallback, }; use rustmotion_core::schema::{ - CharAnimPreset, CharAnimation, FontStyleType, FontWeight, Stroke, TextAlign, + AnimationEffect, CharAnimPreset, CharAnimation, FontStyleType, FontWeight, Stroke, TextAlign, TextAnimGranularity, TextBackground, TextShadow, TimelineStep, }; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; +/// Default starting blur sigma (px) for `char_blur_in` when +/// `CharAnimationTiming.blur` is not set. Tuned against rendered output at +/// 120px display type (see issue #118's render proof): low enough that +/// individual letterforms stay ghost-legible at the start of a unit's +/// reveal (this is a *reveal*, not a smoke effect), high enough that the +/// blur is unmistakable next to the settled, sharp frame. +const DEFAULT_CHAR_BLUR_SIGMA: f32 = 14.0; + #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct Text { pub content: String, @@ -66,6 +74,7 @@ fn apply_text_anim_preset( unit_idx: usize, font_size: f32, overshoot: f32, + blur_radius: f32, ) { let center_x = cursor_x + unit_width / 2.0; let center_y = line_y; @@ -150,6 +159,36 @@ fn apply_text_anim_preset( &p, ); } + CharAnimPreset::BlurIn => { + // One continuous progress value `t` drives all three + // components at once (blur settle, upward drift, opacity + // ramp) rather than sequencing them as separate effects. + let tt = t.clamp(0.0, 1.0); + let offset_y = (1.0 - tt) * font_size * 0.12; + let sigma = ((1.0 - tt) * blur_radius).max(0.0); + let mut p = paint.clone(); + p.set_alpha_f(tt * paint.alpha_f()); + if sigma > 0.05 { + if let Some(filter) = skia_safe::image_filters::blur( + (sigma, sigma), + skia_safe::TileMode::Clamp, + None, + None, + ) { + p.set_image_filter(filter); + } + } + draw_text_with_fallback( + canvas, + text, + font, + emoji_font, + 0.0, + cursor_x, + line_y + offset_y, + &p, + ); + } } } @@ -169,6 +208,7 @@ fn render_char_animation( char_anim: &CharAnimation, time: f64, overshoot: f32, + blur_radius: f32, ) { let is_word_mode = matches!(char_anim.granularity, TextAnimGranularity::Word); let mut global_unit_idx = 0usize; @@ -257,6 +297,7 @@ fn render_char_animation( global_unit_idx, font.size(), overshoot, + blur_radius, ); canvas.restore(); @@ -299,6 +340,7 @@ fn render_char_animation( global_unit_idx, font.size(), overshoot, + blur_radius, ); canvas.restore(); @@ -486,6 +528,50 @@ impl Text { &char_anim, time, resolved.overshoot, + 0.0, // blur is only meaningful for char_blur_in, handled below + ); + return Ok(()); + } + + // `char_blur_in` is read directly off `style.animation` rather than + // through `engine::animator::extract_effects` → `props.char_animation` + // like its five siblings above: that resolution path lives outside + // this workstream's file scope (schema/video.rs + text.rs only, see + // issue #118 / chantier #117 W1). This is a top-level + // `style.animation` entry lookup, so — unlike the branch above — it + // does not pick up container-level stagger delay shifting or + // `timeline`-step-embedded copies. + if let Some(timing) = self.style.animation.iter().find_map(|effect| match effect { + AnimationEffect::CharBlurIn(t) => Some(t), + _ => None, + }) { + let char_anim = CharAnimation { + preset: CharAnimPreset::BlurIn, + granularity: timing.granularity.clone(), + stagger: timing.stagger as f32, + duration: timing.duration as f32, + easing: timing.easing.clone(), + delay: timing.delay as f32, + }; + render_char_animation( + canvas, + &content, + &font, + &emoji_font, + &paint, + letter_spacing, + align, + align_width, + line_height_val, + baseline_offset, + &lines, + &char_anim, + time, + timing.overshoot.unwrap_or(0.08) as f32, + timing + .blur + .map(|b| b as f32) + .unwrap_or(DEFAULT_CHAR_BLUR_SIGMA), ); return Ok(()); } @@ -577,6 +663,7 @@ mod tests { use super::*; use rustmotion_core::css::style::CssStyle; use rustmotion_core::css::Length; + use rustmotion_core::schema::{CharAnimationTiming, EasingType}; fn make_text(content: &str, white_space: Option) -> Text { Text { @@ -706,4 +793,234 @@ mod tests { "wrapped text must spill onto a second line within the box width" ); } + + // ─── char_blur_in ─────────────────────────────────────────────────── + + /// Fraction of "inked" pixels (alpha > 0) in the region that are + /// partially transparent (0 < alpha < 250) rather than solid. A sharp + /// glyph is mostly solid fill with a thin antialiased edge, so this + /// fraction is low. A heavily blurred glyph is a soft gradient + /// wherever it has any ink at all, so this fraction is high. + fn soft_pixel_fraction( + grid: &[u8], + surface_width: i32, + x0: i32, + x1: i32, + y0: i32, + y1: i32, + ) -> f32 { + let mut inked = 0u32; + let mut soft = 0u32; + for y in y0..y1 { + for x in x0..x1 { + let a = grid[(y * surface_width + x) as usize]; + if a > 0 { + inked += 1; + if a < 250 { + soft += 1; + } + } + } + } + if inked == 0 { + return 0.0; + } + soft as f32 / inked as f32 + } + + /// Build the same `Font` the renderer would build for `family`/`px`, so + /// tests can measure exact word boundaries instead of guessing pixel + /// coordinates. + fn inter_font(px: f32) -> Font { + let style = FontStyle::new( + skia_safe::font_style::Weight::NORMAL, + skia_safe::font_style::Width::NORMAL, + skia_safe::font_style::Slant::Upright, + ); + let typeface = typeface_with_fallback("Inter", style).expect("typeface resolves"); + Font::from_typeface(typeface, px) + } + + #[test] + fn char_blur_in_word_is_blurred_mid_reveal_and_sharp_when_settled() { + // Render-level proof that char_blur_in actually blurs: a single + // word must read as measurably softer mid-reveal than once + // settled. Also exercises the DEFAULT_CHAR_BLUR_SIGMA fallback + // (`blur: None`). + let mut text = make_text("BLUR", None); + text.style.font_size = Some(Length::Px(100.0)); + text.style.white_space = Some(CssWhiteSpace::Nowrap); + text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming { + delay: 0.0, + duration: 0.5, + stagger: 0.03, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + overshoot: None, + blur: None, + })]; + + const W: i32 = 700; + const H: i32 = 220; + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + + let render_at = |t: f64| -> Vec { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + text.paint(canvas, W as f32, t, &props, &ctx) + .expect("paint succeeds"); + } + alpha_grid(&mut surface, W, H) + }; + + let early = render_at(0.15); // raw progress 0.15/0.5 = 0.3 into the reveal + let settled = render_at(1.0); // long past duration: sigma → 0, alpha → 1 + + assert!( + has_ink_in(&early, W, 0, W, 0, H), + "the word must have started painting by t=0.15" + ); + + let early_soft = soft_pixel_fraction(&early, W, 0, W, 0, H); + let settled_soft = soft_pixel_fraction(&settled, W, 0, W, 0, H); + + assert!( + early_soft > settled_soft + 0.15, + "mid-reveal soft-pixel fraction ({early_soft:.3}) must be clearly higher than the \ + settled fraction ({settled_soft:.3}) — the word should read as blurred while \ + animating and sharp at rest" + ); + assert!( + settled_soft < 0.25, + "settled frame should read as sharp text, not blur (soft fraction {settled_soft:.3})" + ); + } + + #[test] + fn char_blur_in_whitespace_gap_stays_empty_while_words_animate() { + // The word-mode char path draws inter-word whitespace unanimated + // at full opacity (see render_char_animation) — harmless for the + // six existing opacity-only presets since a space glyph has no + // ink. Pin down that this holds for char_blur_in too: each word's + // blur filter is scoped to that word's own draw call, so it must + // never smear ink into the gap between words. + // + // Note this is *not* the same as "a blurred word's own halo never + // reaches near the gap" — a Gaussian blur legitimately spreads a + // word's own ink a few sigma past its sharp glyph edge, which is + // correct behaviour, not smearing into whitespace. So this checks + // the gap's true center (rendering evidence: crates/.../issue-118 + // render proof measured the same distinction against real render + // output — a word's halo fades to background well within a third + // of a multi-space gap). + const FONT_PX: f32 = 90.0; + let mut text = make_text("FIRST SECOND", None); // 15 spaces + text.style.font_size = Some(Length::Px(FONT_PX)); + text.style.white_space = Some(CssWhiteSpace::Nowrap); + text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming { + delay: 0.0, + duration: 0.4, + stagger: 0.2, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + overshoot: None, + blur: Some(18.0), + })]; + + const W: i32 = 1400; + const H: i32 = 180; + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + + let font = inter_font(FONT_PX); + let first_w = measure_text_with_fallback("FIRST", &font, &None, 0.0); + let gap_w = measure_text_with_fallback(" ", &font, &None, 0.0); + let margin = (gap_w / 3.0).max(40.0); + let gap_x0 = (first_w + margin) as i32; + let gap_x1 = (first_w + gap_w - margin) as i32; + assert!( + gap_x1 > gap_x0, + "test setup: the space run must measure to a real gap (got [{gap_x0},{gap_x1}))" + ); + + for &t in &[0.05_f64, 0.35, 1.0] { + let mut surface = + skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = surface.canvas(); + text.paint(canvas, W as f32, t, &props, &ctx) + .expect("paint succeeds"); + } + let grid = alpha_grid(&mut surface, W, H); + assert!( + !has_ink_in(&grid, W, gap_x0, gap_x1, 0, H), + "inter-word gap [{gap_x0},{gap_x1}) must stay empty at t={t}" + ); + } + } + + #[test] + fn char_blur_in_honors_word_delay_and_stagger() { + // With a stagger larger than the per-word duration, word 2 must + // not have started at all while word 1 is mid-reveal, and nothing + // should paint before `delay` has elapsed either. + const FONT_PX: f32 = 90.0; + let mut text = make_text("ONE TWO", None); + text.style.font_size = Some(Length::Px(FONT_PX)); + text.style.white_space = Some(CssWhiteSpace::Nowrap); + text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming { + delay: 0.5, + duration: 0.3, + stagger: 0.6, + granularity: TextAnimGranularity::Word, + easing: EasingType::Linear, + overshoot: None, + blur: Some(16.0), + })]; + + const W: i32 = 900; + const H: i32 = 180; + let ctx = test_ctx(); + let props = AnimatedProperties::default(); + let font = inter_font(FONT_PX); + let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32; + + // Before `delay`, nothing should paint at all. + let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = before.canvas(); + text.paint(canvas, W as f32, 0.1, &props, &ctx) + .expect("paint succeeds"); + } + let before_grid = alpha_grid(&mut before, W, H); + assert!( + !has_ink_in(&before_grid, W, 0, W, 0, H), + "nothing should paint before `delay` has elapsed" + ); + + // t=0.65s: word 1's local progress is (0.65-0.5)/0.3 = 0.5 (mid + // reveal), word 2 only starts at delay+stagger=1.1s. + let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); + { + let canvas = mid.canvas(); + text.paint(canvas, W as f32, 0.65, &props, &ctx) + .expect("paint succeeds"); + } + let mid_grid = alpha_grid(&mut mid, W, H); + assert!( + has_ink_in(&mid_grid, W, 0, word1_end, 0, H), + "word 1 should show ink by t=0.65 (mid-reveal)" + ); + // Leave enough clearance past word 1's sharp-edge measurement for + // its *own* Gaussian halo (a real, expected effect of blurring + // that word — see the analogous note in the whitespace-gap test + // above), so this only catches an actual word-2 leak. + assert!( + !has_ink_in(&mid_grid, W, word1_end + 70, W, 0, H), + "word 2 (starts at delay+stagger=1.1s) must still be fully invisible at t=0.65" + ); + } } diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index 7e66c23..92b225a 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -73,6 +73,22 @@ pub enum AnimationEffect { CharBounce(CharAnimationTiming), CharRotateIn(CharAnimationTiming), CharSlideUp(CharAnimationTiming), + /// Each word (or char) arrives blurred and settles sharp, combined with + /// a slight upward translate and an opacity ramp — one continuous + /// per-unit animation driven by the same progress value, not three + /// independently-timed effects. `CharAnimationTiming.blur` sets the + /// starting blur sigma in px (default tuned for 100px+ display type; + /// see `crates/rustmotion-components/src/text.rs`). + /// + /// Resolved directly off `style.animation` inside + /// `rustmotion_components::text::Text::paint` rather than through + /// `engine::animator::extract_effects`/`ResolvedCharAnimation` like its + /// five siblings — that resolution path lives in a file outside this + /// workstream's scope (chantier #117 W1). Functionally equivalent for + /// the documented use (a top-level `style.animation` entry); it does + /// not participate in container-level stagger delay shifting the way + /// the other char presets do when nested in a staggered list/grid. + CharBlurIn(CharAnimationTiming), // --- Non-preset effects --- Glow(GlowConfig), Wiggle(WiggleConfig), @@ -102,7 +118,7 @@ impl AnimationEffect { | WipeRight(t) | Float3d(t) => t.delay += by, TiltIn(c) => c.delay += by, CharScaleIn(c) | CharFadeIn(c) | CharWave(c) | CharBounce(c) | CharRotateIn(c) - | CharSlideUp(c) => c.delay += by, + | CharSlideUp(c) | CharBlurIn(c) => c.delay += by, Keyframes(c) => c.delay += by, Glow(_) | Wiggle(_) | Orbit(_) | MotionBlur(_) | Trail(_) => {} } @@ -239,6 +255,14 @@ pub struct CharAnimationTiming { /// Overshoot intensity for char_scale_in/char_bounce (0.0 = none, default 0.08 = 8%). #[serde(default)] pub overshoot: Option, + /// Starting blur sigma in px for char_blur_in — the word (or char) + /// begins blurred by this amount and settles to sharp (sigma 0) by the + /// end of its unit animation. Unused by the other five char presets. + /// Defaults to 14px sigma, chosen to read clearly at 100px+ display + /// type without collapsing into a featureless blob (see render proof + /// in issue #118). + #[serde(default)] + pub blur: Option, } fn default_char_stagger_f64() -> f64 { @@ -252,7 +276,7 @@ fn default_char_duration_f64() -> f64 { /// Per-character or per-word text animation configuration (legacy root-level prop). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct CharAnimation { - /// Animation preset: "scale_in", "fade_in", "wave", "bounce", "rotate_in", "slide_up". + /// Animation preset: "scale_in", "fade_in", "wave", "bounce", "rotate_in", "slide_up", "blur_in". #[serde(default = "default_char_preset")] pub preset: CharAnimPreset, /// Granularity: animate per character or per word. @@ -301,6 +325,10 @@ pub enum CharAnimPreset { RotateIn, /// Each character slides up from below. SlideUp, + /// Each character/word arrives blurred and settles sharp, with a + /// slight upward translate and opacity ramp driven by the same + /// progress value (see `AnimationEffect::CharBlurIn`). + BlurIn, } fn default_char_preset() -> CharAnimPreset { From 1bb1081edaa6caf9308644905af62d303069d102 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 1 Aug 2026 19:11:34 +0200 Subject: [PATCH 02/10] feat(background): give halo zones an explicit opacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The halo preset is the right home for the ambient glow of a dark video: it is a background, so it escapes geometry validation, and the renderer draws it continuously rather than per scene. But HaloZone exposed only colour, position and radius, so the only way to soften a glow was to bury alpha inside the hex string. With opaque colours it renders a flat saturated wall instead of glows — reproducing the reference look took two attempts and a lot of guessing. opacity defaults to 1.0 and multiplies whatever alpha the colour already carries, so it never overrides an author's intent. The default is a true no-op: a scenario with the field absent and one with opacity 1.0 render byte-identical PNGs, which is guaranteed rather than incidental since f32 * 1.0 is exact. Measured falloff at 1.0 / 0.5 / 0.2: centre pixel R = 255 / 128 / 51. Refs #119 --- .../rustmotion-core/src/schema/background.rs | 93 +++++++++++++- .../src/engine/render/background.rs | 121 +++++++++++++++++- 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/crates/rustmotion-core/src/schema/background.rs b/crates/rustmotion-core/src/schema/background.rs index 75458bc..464d7ea 100644 --- a/crates/rustmotion-core/src/schema/background.rs +++ b/crates/rustmotion-core/src/schema/background.rs @@ -359,7 +359,8 @@ impl JsonSchema for AnimatedBackground { /// A single glow zone for the "halo" animated-background preset. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct HaloZone { - /// Zone color (hex string). + /// Zone color (hex string). May itself carry an alpha channel + /// (`#rrggbbaa`); see [`HaloZone::opacity`] for how the two combine. pub color: String, /// X position as fraction of width (0.0 = left, 1.0 = right). #[serde(default = "default_half")] @@ -370,6 +371,15 @@ pub struct HaloZone { /// Radius as fraction of max(width, height). #[serde(default = "default_halo_radius")] pub radius: f32, + /// Zone opacity, multiplied with any alpha already encoded in `color`. + /// + /// Default `1.0` is a true no-op: it leaves `color`'s own alpha (opaque + /// or hex-encoded) untouched, so scenarios written before this field + /// existed — including ones that hid alpha inside the hex string, e.g. + /// `#1E3A8A55` — keep rendering identically. Values are clamped to + /// `0.0..=1.0`. + #[serde(default = "default_halo_opacity")] + pub opacity: f32, } /// Transition configuration for background interpolation between scenes. @@ -429,6 +439,10 @@ fn default_halo_radius() -> f32 { 0.4 } +fn default_halo_opacity() -> f32 { + 1.0 +} + fn default_bg_element_size() -> f32 { 4.0 } @@ -549,3 +563,80 @@ where deserializer.deserialize_any(BgVisitor) } + +#[cfg(test)] +mod halo_zone_opacity_tests { + use super::*; + + #[test] + fn halo_zone_opacity_defaults_to_1_when_omitted() { + let zone: HaloZone = + serde_json::from_value(serde_json::json!({ "color": "#1E3A8A55" })).unwrap(); + assert_eq!(zone.opacity, 1.0); + } + + #[test] + fn halo_zone_opacity_respects_explicit_value() { + let zone: HaloZone = serde_json::from_value(serde_json::json!({ + "color": "#1E3A8A", + "opacity": 0.35 + })) + .unwrap(); + assert_eq!(zone.opacity, 0.35); + } + + #[test] + fn halo_zone_serializes_opacity() { + let zone = HaloZone { + color: "#1E3A8A".to_string(), + x: 0.5, + y: 0.5, + radius: 0.4, + opacity: 0.6, + }; + let v = serde_json::to_value(&zone).unwrap(); + // Compare as f64 with a tolerance: 0.6f32 widened to f64 is + // 0.6000000238418579, not exactly 0.6 — an f32 precision artifact, + // not a bug in the field itself. + let got = v["opacity"] + .as_f64() + .expect("opacity must serialize as a number"); + assert!((got - 0.6).abs() < 1e-6, "got {got}"); + } + + #[test] + fn animated_background_new_format_halo_zone_defaults_opacity() { + // New nested format: {"preset":"halo","halo":{"zones":[...]}} + let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({ + "preset": "halo", + "halo": { "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }] }, + "speed": 0 + })) + .unwrap(); + match bg.preset { + BackgroundPreset::Halo(cfg) => { + assert_eq!(cfg.zones.len(), 1); + assert_eq!(cfg.zones[0].opacity, 1.0); + // Alpha-in-hex is untouched by the schema layer — it stays in `color`. + assert_eq!(cfg.zones[0].color, "#1E3A8A55"); + } + _ => panic!("expected Halo preset"), + } + } + + #[test] + fn animated_background_legacy_flat_format_halo_zone_defaults_opacity() { + // Legacy flat format: {"preset":"halo","zones":[...]} with no sub-object. + let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({ + "preset": "halo", + "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }] + })) + .unwrap(); + match bg.preset { + BackgroundPreset::Halo(cfg) => { + assert_eq!(cfg.zones[0].opacity, 1.0); + } + _ => panic!("expected Halo preset"), + } + } +} diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index 2258198..fcb929c 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -211,7 +211,11 @@ fn draw_bg_halo(canvas: &Canvas, cfg: &HaloConfig, speed: f32, time: f32, width: let breath = 1.0 + 0.15 * (time * freq + phase).sin(); let radius = base_radius * breath; - let color = color4f_from_hex(&zone.color); + let mut color = color4f_from_hex(&zone.color); + // `opacity` multiplies whatever alpha `color` already carries (opaque + // by default, or hex-encoded, e.g. `#1E3A8A55`). Default 1.0 is a + // true no-op — it leaves the colour's own alpha untouched. + color.a *= zone.opacity.clamp(0.0, 1.0); let mut paint = Paint::default(); paint.set_anti_alias(true); paint.set_color4f(color, None); @@ -436,6 +440,7 @@ pub(super) fn interpolate_animated_bg( x: lerp(za.x, zb.x), y: lerp(za.y, zb.y), radius: lerp(za.radius, zb.radius), + opacity: lerp(za.opacity, zb.opacity), }); } (None, Some(zb)) => out.push(zb.clone()), @@ -528,3 +533,117 @@ pub(super) fn compute_scroll_offset(bg: &AnimatedBackground, time: f32) -> (f32, }; (dx * speed * time, dy * speed * time) } + +#[cfg(test)] +mod halo_opacity_tests { + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + use crate::loader::load_scenario_from_source; + + /// Render frame 0 of the first scene in a scenario JSON string. + fn render_first_frame(json: &str) -> Vec { + let scenario = load_scenario_from_source(None, Some(json)).expect("load"); + let tasks = build_frame_tasks(&scenario); + let task = tasks + .iter() + .find(|t| matches!(t, FrameTask::Normal { .. })) + .expect("normal task"); + render_frame_task(&scenario.video, &scenario, task).expect("render") + } + + /// A single centered white halo zone on a black ground, `opacity` templated in. + fn halo_scenario(opacity_field: &str) -> String { + format!( + r##"{{"video":{{"width":100,"height":100,"background":"#000000"}}, + "scenes":[{{"duration":1.0, + "background":{{"preset":"halo","speed":0, + "zones":[{{"color":"#FFFFFF","x":0.5,"y":0.5,"radius":0.3{opacity_field}}}]}} + ,"children":[]}}]}}"## + ) + } + + fn center_rgba(buf: &[u8], width: u32) -> (u8, u8, u8, u8) { + let base = (50 * width as usize + 50) * 4; + (buf[base], buf[base + 1], buf[base + 2], buf[base + 3]) + } + + #[test] + fn opacity_defaults_to_1_and_is_a_true_noop() { + // Explicit 1.0 and an entirely absent field must render byte-identically: + // multiplying an f32 alpha by 1.0 is an exact IEEE-754 identity, so this + // also proves the default composes as a no-op with the color's own alpha. + let with_field = render_first_frame(&halo_scenario(r#","opacity":1.0"#)); + let without_field = render_first_frame(&halo_scenario("")); + assert_eq!( + with_field, without_field, + "default opacity must be pixel-identical to an explicit 1.0" + ); + } + + #[test] + fn opacity_scales_alpha_monotonically() { + let buf_1_0 = render_first_frame(&halo_scenario(r#","opacity":1.0"#)); + let buf_0_5 = render_first_frame(&halo_scenario(r#","opacity":0.5"#)); + let buf_0_2 = render_first_frame(&halo_scenario(r#","opacity":0.2"#)); + + let (r1, ..) = center_rgba(&buf_1_0, 100); + let (r05, ..) = center_rgba(&buf_0_5, 100); + let (r02, ..) = center_rgba(&buf_0_2, 100); + + assert!( + r1 > r05 && r05 > r02, + "expected monotonic falloff: opacity=1.0 -> {r1}, 0.5 -> {r05}, 0.2 -> {r02}" + ); + // White zone over a black ground: center pixel ≈ 255 * opacity. + assert_eq!(r1, 255); + assert!( + (r05 as i32 - 128).abs() <= 2, + "0.5 opacity center was {r05}" + ); + assert!((r02 as i32 - 51).abs() <= 2, "0.2 opacity center was {r02}"); + } + + #[test] + fn opacity_multiplies_the_colors_own_hex_alpha() { + // #1E3A8A55 already carries alpha 0x55 (~0.333). opacity 0.5 must + // multiply through to an effective alpha of ~0.1667, not override it. + let bg = "#05060A"; + let scenario = format!( + r##"{{"video":{{"width":100,"height":100,"background":"{bg}"}}, + "scenes":[{{"duration":1.0, + "background":{{"preset":"halo","speed":0, + "zones":[{{"color":"#1E3A8A55","x":0.5,"y":0.5,"radius":0.35,"opacity":0.5}}]}} + ,"children":[]}}]}}"## + ); + let buf = render_first_frame(&scenario); + let (r, g, b, _a) = center_rgba(&buf, 100); + + let effective_alpha = (0x55 as f32 / 255.0) * 0.5; + let expect = |fg: u8, bgc: u8| -> f32 { + fg as f32 * effective_alpha + bgc as f32 * (1.0 - effective_alpha) + }; + let (er, eg, eb) = (expect(0x1E, 0x05), expect(0x3A, 0x06), expect(0x8A, 0x0A)); + + assert!((r as f32 - er).abs() <= 3.0, "r={r} expected~{er}"); + assert!((g as f32 - eg).abs() <= 3.0, "g={g} expected~{eg}"); + assert!((b as f32 - eb).abs() <= 3.0, "b={b} expected~{eb}"); + } + + #[test] + fn opacity_is_clamped_to_0_1_range() { + let over_one = render_first_frame(&halo_scenario(r#","opacity":2.5"#)); + let clamped_one = render_first_frame(&halo_scenario(r#","opacity":1.0"#)); + assert_eq!( + over_one, clamped_one, + "opacity > 1.0 must clamp to the same result as 1.0" + ); + + let negative = render_first_frame(&halo_scenario(r#","opacity":-1.0"#)); + let (r, g, b, _a) = center_rgba(&negative, 100); + // Fully clamped to 0 alpha: only the black scene background shows through. + assert_eq!( + (r, g, b), + (0, 0, 0), + "negative opacity must clamp to fully transparent" + ); + } +} From ac0bfaddaee67bef117ccb262a2ba866bef1e5dc Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 1 Aug 2026 19:11:34 +0200 Subject: [PATCH 03/10] fix(validate): let a component declare deliberate bleed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A radial glow running past the frame edge is the base layer of the reference style, and the validator rejected it as viewport overflow. The obvious workaround — wrapping it in a full-frame overflow: hidden container — passes validation and then destroys the effect it was protecting: the container draws a hard rectangle, and during a camera pan that edge sits in plain view. bleed: true exempts a component from viewport_overflow and, under --strict-anim, from animated_text_overflow. It deliberately does not exempt content_overflows_box: a component may extend past the frame on purpose, but it is still responsible for its own contents. Nor does it suppress checks on children. This extends the existing exemption — geometry.rs already exempts marquee and cursor, with a comment saying their job is to bleed — rather than adding a second mechanism. Chosen over inferring which shapes look decorative: a shape is very often real content, and a wrong guess would silently hide a genuine overflow. Also adds bleed to WRAPPER_KEYS. Without it the attribute checker, blocking by default since it was hardened, rejected bleed as unknown — the geometry was correct while the CLI refused the file, which would have shipped the feature dead. Refs #120 --- .../rustmotion-cli/src/commands/geometry.rs | 190 +++++++++++++++++- .../src/commands/validate_attrs.rs | 2 +- .../rustmotion-components/src/box_builder.rs | 13 ++ .../src/legacy_dispatch.rs | 4 + crates/rustmotion-components/src/lib.rs | 12 ++ .../tests/caption_presets.rs | 1 + crates/rustmotion/src/tests.rs | 25 +++ 7 files changed, 245 insertions(+), 2 deletions(-) diff --git a/crates/rustmotion-cli/src/commands/geometry.rs b/crates/rustmotion-cli/src/commands/geometry.rs index 823a8d1..b44bd4c 100644 --- a/crates/rustmotion-cli/src/commands/geometry.rs +++ b/crates/rustmotion-cli/src/commands/geometry.rs @@ -206,7 +206,7 @@ fn walk( let raw_bbox = bbox_of(layout); if !is_exempted(&child.component) { - if !parent_clips { + if !parent_clips && !bleeds(child) { let mut vbbox = apply_static_node_transform(&raw_bbox, &box_node.css, viewport_f); if let Some(cam) = camera { vbbox = fold_static_camera(&vbbox, cam, viewport_f); @@ -280,6 +280,31 @@ fn is_exempted(c: &Component) -> bool { matches!(c, Component::Marquee(_) | Component::Cursor(_)) } +/// Extends the exemption above with an *opt-in* declaration: a component +/// author who sets top-level `bleed: true` (see `ChildComponent::bleed`) is +/// asserting that extending past the frame is this component's job — a +/// radial glow used as a base layer, the same category as `marquee`/`cursor` +/// but not knowable from the component's *type* alone (a shape is very often +/// real, non-bleeding content). +/// +/// Deliberately narrower than [`is_exempted`]: that one is checked once at +/// the top of `walk`/`walk_anim` and suppresses every check in the block +/// below it, `content_overflows_box` included. `bleed` must NOT do that — +/// content larger than its own box is a different defect, unrelated to +/// whether the box itself is allowed to cross the viewport edge, and staying +/// reported is the whole point of `content_overflows_box` existing. So this +/// is consulted individually at each of the two call sites it's allowed to +/// affect (`check_viewport` in `walk`, the animated-overflow check in +/// `walk_anim`) rather than folded into `is_exempted`. +/// +/// `bleed` lives on `ChildComponent`, one per component instance, so a +/// parent declaring it never reaches its children: each child in a +/// container's own `children: Vec` carries its own `bleed` +/// (default `false`), untouched by the parent's. +fn bleeds(child: &ChildComponent) -> bool { + child.bleed +} + fn container_children(c: &Component) -> Option<&[ChildComponent]> { match c { Component::Card(card) => Some(&card.children), @@ -1011,6 +1036,7 @@ fn walk_anim( } if !is_exempted(&child.component) + && !bleeds(child) && !parent_clips && layout.width > 0.5 && layout.height > 0.5 @@ -1999,6 +2025,168 @@ mod tests { .iter() .any(|v| v.kind == ViolationKind::UnwrappableTextOverflow)); } + + // ─── #120: opt-in `bleed: true` exempts viewport/animated overflow only ── + + fn bleeding_shape_json(bleed: bool) -> String { + // Identical to `shape_past_right_edge_triggers_x_overflow`'s fixture + // (a 400×100 shape at x=1700, spilling 180px past the 1920-wide + // viewport) plus the top-level `bleed` field under test — mirrors + // the reference films' radial-glow base layer. + format!( + r##"{{ + "video": {{ "width": 1920, "height": 1080 }}, + "scenes": [{{ + "duration": 1.0, + "children": [{{ + "type": "shape", + "shape": "rect", + "style": {{ "width": "400px", "height": "100px" }}, + "position": "absolute", + "x": 1700, "y": 100, + "fill": "#ff0000"{} + }}] + }}] + }}"##, + if bleed { r#", "bleed": true"# } else { "" } + ) + } + + #[test] + fn bleeding_shape_with_bleed_true_validates_clean() { + let scenario = parse(&bleeding_shape_json(true)); + let violations = validate_geometry(&scenario); + assert!( + violations.is_empty(), + "bleed: true must exempt the shape from ViewportOverflow: {:?}", + violations + ); + } + + #[test] + fn identical_fixture_without_bleed_still_errors() { + // Same fixture, `bleed` omitted (defaults to false) — proves the + // default doesn't change existing behaviour and that the exemption + // above is actually driven by the field, not something else in the + // fixture. + let scenario = parse(&bleeding_shape_json(false)); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .any(|v| v.kind == ViolationKind::ViewportOverflow && v.component == "shape"), + "without bleed, the identical shape must still be reported: {:?}", + violations + ); + } + + #[test] + fn bleed_true_does_not_exempt_content_overflows_box() { + // Exact fixture from `wrapped_text_taller_than_its_fixed_height_card_is_flagged` + // (a card comfortably inside frame, wrapping a paragraph that needs + // ~343px but the card is fixed at 80px tall) with `bleed: true` + // added to the text — content larger than its own box is a + // different defect than crossing the viewport edge, and must stay + // reported regardless of `bleed` (per #120's explicit non-goal). + let json = r##"{"video":{"width":960,"height":540,"fps":30,"background":"#0A0A12"}, + "scenes":[{"duration":1.0,"children":[ + {"type":"card","position":"absolute","x":330,"y":200, + "style":{"width":300,"height":80,"background":"#1e2233","overflow":"visible"}, + "children":[{"type":"text","bleed":true, + "content":"Ce paragraphe est beaucoup plus grand que la carte de 80px qui le contient.", + "style":{"font-size":44,"color":"#ffffff"}}]}]}]}"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .any(|v| v.kind == ViolationKind::ContentOverflowsBox && v.component == "text"), + "bleed: true on the text must NOT suppress ContentOverflowsBox: {:?}", + violations + ); + } + + #[test] + fn bleed_on_a_parent_does_not_suppress_a_childs_viewport_overflow() { + // A container declares `bleed: true` and sits entirely inside the + // frame itself (x=50,y=50, 200×200 — no overflow of its own). Its + // child shape is absolutely positioned far enough (relative to the + // container's own box) to spill past the 1920-wide viewport on its + // own merits. `bleed` lives on the child's own `ChildComponent`, one + // per component instance — the parent's `bleed: true` must not reach + // down into the child's, which was never set. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 1.0, + "children": [{ + "type": "container", + "bleed": true, + "position": "absolute", + "x": 50, "y": 50, + "style": { "width": "200px", "height": "200px" }, + "children": [{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 1900, "y": 100, + "style": { "width": "300px", "height": "100px" }, + "fill": "#ff0000" + }] + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations.iter().all(|v| v.component != "container"), + "the container itself sits inside the frame and must not be reported: {:?}", + violations + ); + assert!( + violations + .iter() + .any(|v| v.kind == ViolationKind::ViewportOverflow && v.component == "shape"), + "the parent's bleed:true must not suppress the child's own genuine overflow: {:?}", + violations + ); + } + + #[test] + fn bleed_true_exempts_animated_text_overflow_too() { + // Same animation-overflow fixture as `strict_anim_detects_slide_in_overflow` + // (slide_in_left pushes a resting shape at x=100 strongly negative + // during the first fraction of the preset) with `bleed: true` added + // — `--strict-anim`'s AnimatedTextOverflow must be exempted exactly + // like the resting-layout ViewportOverflow check. + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 2.0, + "children": [{ + "type": "shape", + "shape": "rect", + "position": "absolute", + "x": 100, "y": 100, + "bleed": true, + "style": { + "width": "100px", "height": "100px", + "animation": [{ "name": "slide_in_left", "delay": 0, "duration": 1.0 }] + }, + "fill": "#ff0000" + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry_animated(&scenario); + assert!( + violations + .iter() + .all(|v| v.kind != ViolationKind::AnimatedTextOverflow), + "bleed: true must exempt the shape from AnimatedTextOverflow: {:?}", + violations + ); + } } /// M4 (issue #110 / #102): legibility floor tests. diff --git a/crates/rustmotion-cli/src/commands/validate_attrs.rs b/crates/rustmotion-cli/src/commands/validate_attrs.rs index db66822..16d5d26 100644 --- a/crates/rustmotion-cli/src/commands/validate_attrs.rs +++ b/crates/rustmotion-cli/src/commands/validate_attrs.rs @@ -35,7 +35,7 @@ use rustmotion::schema::ResolvedScenario; /// - `animation`: top-level `animation` is ignored by the engine and already /// reported by the dedicated misplaced-animation warning — flagging it here /// too would double-report. -const WRAPPER_KEYS: &[&str] = &["position", "x", "y", "z-index", "animation"]; +const WRAPPER_KEYS: &[&str] = &["position", "x", "y", "z-index", "animation", "bleed"]; /// Serde enum aliases that schemars does not know about: alias tag → schema tag. const TAG_ALIASES: &[(&str, &str)] = &[("container", "div"), ("progress_bar", "progress")]; diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 6c38ab9..e27e6c5 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -1450,6 +1450,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, } } @@ -1477,6 +1478,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }]; let built = build_scene(&scene, (800.0, 600.0)); let card_box = &built.root.children[0]; @@ -1507,6 +1509,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }]; let built = build_scene(&scene, (1920.0, 1080.0)); assert_eq!(built.root.children.len(), 1); @@ -1556,6 +1559,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }]; let built = build_scene(&scene, (400.0, 400.0)); let layout = run_layout(&built.root, (400.0, 400.0), &ConversionContext::default()); @@ -1584,6 +1588,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![divider]; let built = build_scene(&scene, (800.0, 600.0)); @@ -1623,6 +1628,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let card = ChildComponent { @@ -1644,6 +1650,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![card]; @@ -1702,6 +1709,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![arrow]; let built = build_scene(&scene, (800.0, 600.0)); @@ -1737,6 +1745,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![conn]; let built = build_scene(&scene, (800.0, 600.0)); @@ -1779,6 +1788,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![counter]; let built = build_scene(&scene, (800.0, 600.0)); @@ -1824,6 +1834,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![badge]; let built = build_scene(&scene, (400.0, 200.0)); @@ -1863,6 +1874,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![line]; let built = build_scene(&scene, (800.0, 600.0)); @@ -1917,6 +1929,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![rich_text]; let built = build_scene(&scene, (800.0, 600.0)); diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 184a7c7..3b0b023 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -189,6 +189,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, } } @@ -276,6 +277,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let card = ChildComponent { @@ -297,6 +299,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![card]; @@ -390,6 +393,7 @@ mod tests { x: None, y: None, z_index: None, + bleed: false, }; vec![shape] }; diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index 9fe2973..b053588 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -153,6 +153,18 @@ pub struct ChildComponent { pub y: Option, #[serde(default, rename = "z-index")] pub z_index: Option, + /// Declares that this component's job is to extend past the frame edge + /// (e.g. a radial glow used as a base layer). Top-level field, not a + /// `style` property — `CssStyle` is `deny_unknown_fields` and belongs to + /// no one this wave. Defaults to `false`: no existing scenario changes + /// behaviour. Exempts only `viewport_overflow` and `animated_text_overflow` + /// (see `crates/rustmotion-cli/src/commands/geometry.rs`); it does NOT + /// exempt `content_overflows_box` — content larger than its own box stays + /// a reported defect regardless of `bleed`. Applies to this component + /// only: a bled container does not suppress checks on its children, since + /// each child is its own `ChildComponent` with its own `bleed` flag. + #[serde(default)] + pub bleed: bool, } impl ChildComponent { diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs index d055f31..6b5a680 100644 --- a/crates/rustmotion-components/tests/caption_presets.rs +++ b/crates/rustmotion-components/tests/caption_presets.rs @@ -26,6 +26,7 @@ fn render_caption(json: serde_json::Value, time: f64) -> Vec { x: None, y: None, z_index: None, + bleed: false, }; let children = vec![child]; diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 07e0368..a1ca7a3 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -318,6 +318,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![child]; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -381,6 +382,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let card_style = CssStyle { flex_direction: Some(FlexDirection::Column), @@ -402,6 +404,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![card_child]; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { @@ -515,6 +518,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![child]; let new_buf = render_new(&scene, 400, 300); @@ -549,6 +553,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![child]; let early = render_new_at(&scene, 400, 300, 0.05, 1.0); @@ -583,6 +588,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }] } @@ -641,6 +647,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let buf = render_new_at(&[child], 400, 300, 0.5, 1.0); assert!( @@ -730,6 +737,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let buf = render_new_at(&[child], 300, 200, 0.25, 2.0); // Children flow in a column with a 10px gap: bands 0..40, 50..90, 100..140. @@ -817,6 +825,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![child]; let blue_sum = |buf: &[u8]| -> u64 { buf.chunks_exact(4).map(|p| p[2] as u64).sum() }; @@ -917,6 +926,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![child]; let early = render_new_at(&scene, 400, 300, 0.05, 1.0); @@ -956,6 +966,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![child]; let early = render_new_at(&scene, 500, 300, 0.05, 1.0); @@ -1001,6 +1012,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let card_style = CssStyle { @@ -1021,6 +1033,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![card_child]; let built = build_scene(&scene, (1920.0, 1080.0)); @@ -1175,6 +1188,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }] } @@ -1276,6 +1290,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }] }; @@ -1329,6 +1344,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }] }; @@ -1383,6 +1399,7 @@ mod component_smoke { x: None, y: None, z_index: None, + bleed: false, }] }; @@ -1463,6 +1480,7 @@ mod svg_draw_on_tests { x: None, y: None, z_index: None, + bleed: false, }; let scene = vec![child]; @@ -1767,6 +1785,7 @@ mod audio_tests { x: None, y: None, z_index: None, + bleed: false, } } @@ -2178,6 +2197,7 @@ mod motion_blur_trail { x: None, y: None, z_index: None, + bleed: false, }] } @@ -2203,6 +2223,7 @@ mod motion_blur_trail { x: None, y: None, z_index: None, + bleed: false, }] } @@ -2259,6 +2280,7 @@ mod motion_blur_trail { x: None, y: None, z_index: None, + bleed: false, }] }; let with_1 = make_motion_blur_scene(1, 1.0); @@ -2298,6 +2320,7 @@ mod motion_blur_trail { x: None, y: None, z_index: None, + bleed: false, }] }; let with_blur = { @@ -2320,6 +2343,7 @@ mod motion_blur_trail { x: None, y: None, z_index: None, + bleed: false, }] }; @@ -2371,6 +2395,7 @@ mod motion_blur_trail { x: None, y: None, z_index: None, + bleed: false, }] }; let with_trail = make_trail_scene(3, 0.1, 0.6); From d5481e657b279b65216e80683d5046deea323193 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 1 Aug 2026 19:11:34 +0200 Subject: [PATCH 04/10] docs(skills): document the world view and the seamless-video recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The world view is the engine's spatial composition mode and appeared in no authoring rule — not composition, not world-position, not camera_pan_duration, not persist. A generator cannot produce what is not written down, which is why the capability audit had to rediscover it from source, getting the coordinate model wrong twice on the way. Two things the docs now state explicitly because they cost real time: world-position is the camera waypoint, not the scene's origin, and its default is vw/2 + i*vw; and children use scene-local coordinates, so a child placed at world coordinates is rejected as viewport overflow. Corrects a claim the audit made: CameraPan is not merely a crossfade. Between scenes of a slide view it dispatches to a real foreground pan over a static background; it degrades to a crossfade only at a view boundary. That one enum variant meaning two different things is noted as a footgun. Also notes that scene.transition is silently ignored inside a world view, and the world-position kebab / freeze_at snake casing inconsistency. Every fenced JSON block added was extracted and validated. Refs #121 --- .claude/skills/rustmotion/SKILL.md | 46 ++++ .claude/skills/rustmotion/rules/world-view.md | 200 ++++++++++++++++++ CLAUDE.md | 10 + 3 files changed, 256 insertions(+) create mode 100644 .claude/skills/rustmotion/rules/world-view.md diff --git a/.claude/skills/rustmotion/SKILL.md b/.claude/skills/rustmotion/SKILL.md index 2b6d758..fbcaa49 100644 --- a/.claude/skills/rustmotion/SKILL.md +++ b/.claude/skills/rustmotion/SKILL.md @@ -223,6 +223,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/stagger-animations.md](rules/stagger-animations.md) - Stagger animations with increasing style.animation.delay - [rules/layer-order.md](rules/layer-order.md) - Layer order matters: first in array = behind, last = front - [rules/card-flex-layout.md](rules/card-flex-layout.md) - Scene = implicit flex container; use card/flex for nested layout +- [rules/world-view.md](rules/world-view.md) - **CRITICAL:** `world` view = the only mechanism for real continuity across beats (no scene-boundary cuts); `world-position` coordinate model + ambient-halo recipe - [rules/continuous-presets.md](rules/continuous-presets.md) - Continuous presets (pulse, float, shake, spin) need loop: true - [rules/timing-constraints.md](rules/timing-constraints.md) - Timing: start_at must be < end_at, duration > 0 - [rules/icon-format.md](rules/icon-format.md) - Icons use Iconify (200k+ icons), format "prefix:name" (e.g. "lucide:home") @@ -550,6 +551,11 @@ The two examples below are short excerpts. For full, validated, end-to-end scena | `layout` | object | `null` | Scene-level flex layout (see below) | | `transition` | object | `null` | Transition to this scene from the previous one | | `freeze_at` | f64 | `null` | Freeze the scene at this time (seconds) | +| `world-position` | `{x, y}` | `null` | **(world view only)** Camera waypoint for this scene — NOT the scene's origin. See [rules/world-view.md](rules/world-view.md). | +| `persist` | bool | `false` | **(world view only)** Keep this scene's content visible (fully opaque) after its own time window ends | +| `camera` | object | `null` | Virtual camera (pan/zoom/rotation) — see [Virtual Camera](#virtual-camera) below | + +Note the casing: `world-position` is kebab-case, `freeze_at` is snake_case — a real inconsistency in the schema, not a typo. Copy the field name exactly as shown. Each scene is an **implicit flex container** at video dimensions. All children participate in flex flow. Children with `position` inside a `card` become absolute. Default direction: `column`. @@ -583,6 +589,43 @@ Think of scene composition exactly like HTML/CSS: **prefer normal flow** (flex c **Decorative/background shapes must always be absolute** — a non-absolute shape or particle in the flex flow consumes height and can push content off-center or to the bottom of the screen. Always add `"position": "absolute", "x": 0, "y": 0` to ambient shapes and particles. +#### Views & Composition (`composition`) + +`scenes` at the scenario root is shorthand for a single implicit `slide` view. For multiple **views** — or to unlock the `world` view — use `composition` instead. `composition` and root-level `scenes` are mutually exclusive (using both is an error). + +```json +{ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30 }, + "composition": [ + { "type": "slide", "scenes": [ { "duration": 3.0, "children": [ { "type": "text", "content": "Slide beat" } ] } ] }, + { + "type": "world", + "camera_pan_duration": 1.0, + "camera_easing": "ease_in_out", + "background": { "preset": "halo", "zones": [ { "color": "#6366F1AA", "x": 0.3, "y": 0.4, "radius": 0.5 } ] }, + "scenes": [ + { "duration": 2.5, "children": [ { "type": "text", "content": "World beat one" } ] }, + { "duration": 2.5, "children": [ { "type": "text", "content": "World beat two" } ] } + ] + } + ] +} +``` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `type` | enum | `"slide"` | `"slide"` (scene-to-scene, coupe/transition) or `"world"` (continuous virtual camera) | +| `scenes` | array | `[]` | Same scene objects as root `scenes` (supports `include` too) | +| `transition` | object | `null` | Transition **entering this view** from the previous view (same shape as a scene `transition`) | +| `background` | string/object | `null` | Shared background for the whole view — for `world`, this is where the ambient glow layer belongs (`preset: "halo"`), never as per-scene shapes | +| `camera_easing` | enum | `"ease_in_out"` | **(world)** Easing for the camera pan between scene waypoints | +| `camera_pan_duration` | f64 | `0.8` | **(world)** Duration (seconds) of the camera pan at each scene boundary | + +**`slide` views** are what the rest of this document describes: scenes render in sequence, `transition` composites two already-rendered frame buffers (fade/wipe/zoom/…) — no element survives the cut. + +**`world` views** are the only mechanism that produces real continuity between beats: a single virtual camera glides between scene waypoints (`world-position`), with a crossfade during the pan instead of a hard cut, over a shared `background`. Full recipe, coordinate model, and a validated multi-beat example: [rules/world-view.md](rules/world-view.md). + #### Include (Composable Scenarios) Scene entries can reference external scenario files to inject their scenes inline: @@ -2263,9 +2306,12 @@ With `transition`, background properties (colors, speed, spacing, element_size, | `element_size` | f32 | `4.0` | Dot/circle size for grid_dots; stroke width for concentric_circles | | `spacing` | f32 | `60.0` | Element spacing for grid_dots/concentric_circles | | `count` | u32 | `null` | Number of circles for concentric_circles (overrides spacing) | +| `zones` | array | `[]` | `halo` only — `[{ "color": "#hex", "x": 0.0-1.0, "y": 0.0-1.0, "radius": 0.0-1.0 }]`. `x`/`y` are fractions of width/height, `radius` a fraction of `max(width, height)`. | | `$ref` | string | `null` | Reference to a named template in `backgrounds` | | `transition` | object | `null` | `{ "duration": f64, "easing": "ease_in_out" }` — interpolates from prev scene | +The same `background` field also exists at the **view** level (`composition[].background`) — that's the recommended place for an ambient `halo` glow in a `world` view, since a per-scene shape glow either fails viewport validation or, once clipped to pass, becomes a visible hard-edged rectangle during a camera pan. See [rules/world-view.md](rules/world-view.md). + --- ### Additional Style Fields diff --git a/.claude/skills/rustmotion/rules/world-view.md b/.claude/skills/rustmotion/rules/world-view.md new file mode 100644 index 0000000..cd70893 --- /dev/null +++ b/.claude/skills/rustmotion/rules/world-view.md @@ -0,0 +1,200 @@ +# Rule: The `world` View — Real Continuity Between Beats + +Every other composition mechanism in rustmotion — `scenes`, `slide` views, `transition` — cuts. A `slide`-view `transition` composites two already-rendered RGBA frame buffers (`crates/rustmotion-core/src/engine/transition.rs`): fade, wipe, zoom, flip, iris, slide, dissolve all blend or clip two flat images. No live element, no shared background, no camera state survives from scene A into scene B — by the time the transition runs, both scenes have already been fully painted in isolation. + +The **`world` view** is different: one virtual camera moves continuously through a 2D space, and scenes are positioned in that space rather than replacing each other. This is the only mechanism that produces a video reading as one continuous shot across several beats — the effect used in things like the Machina/Aikido-style "endless dolly" promo. + +Use a `world` view whenever the brief calls for "no perceptible scene boundaries" or "continuous camera movement." Use `slide` views (the default) for everything else — they're simpler and every other rule in this skill assumes them. + +--- + +## 1. Turning it on: `composition` + +`world` only exists inside `composition`, the typed-views array. Root-level `scenes` is sugar for a single implicit `slide` view and cannot mix with `composition` (using both is a validation error). + +```json +{ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30 }, + "composition": [ + { + "type": "world", + "camera_pan_duration": 1.0, + "camera_easing": "ease_in_out", + "background": { "preset": "halo", "zones": [ { "color": "#6366F1AA", "x": 0.3, "y": 0.4, "radius": 0.5 } ] }, + "scenes": [ + { "duration": 2.5, "children": [ { "type": "text", "content": "Beat one" } ] }, + { "duration": 2.5, "children": [ { "type": "text", "content": "Beat two" } ] } + ] + } + ] +} +``` + +View-level fields relevant to `world`: `camera_pan_duration` (f64, default `0.8`), `camera_easing` (easing enum, default `"ease_in_out"`), `background` (shared, view-wide — see §4). A `slide` view can precede or follow a `world` view in the same `composition`; the outer `view.transition` field (a plain frame-buffer crossfade/wipe, same engine as scene transitions) handles the join between views. + +--- + +## 2. The coordinate model — read this before writing `world-position` + +This is the part that catches people twice, because nothing in the JSON shape hints at it. + +### `world-position` is a camera waypoint, not the scene's origin + +Source: `crates/rustmotion/src/engine/world.rs`, `WorldTimeline::build` (~line 74-86) and `crates/rustmotion/src/engine/render/scene.rs`, `render_world_frame_scaled` (~line 838-855). Both read a scene's `world-position` the same way: it's the point the camera centers on when it arrives at that scene, and it's also where the scene's own center gets translated to. If you omit it, the default is: + +``` +x = video_width / 2 + i * video_width +y = video_height / 2 +``` + +(`i` = the scene's index in the view's `scenes` array.) That default already lays scenes out as a horizontal filmstrip, each one `video_width` px apart, so **most of the time you don't need to set `world-position` at all** — just add scenes and let the camera dolly sideways through them. + +**The mistake:** because the default formula uses `i * video_width`, it's tempting to hand-set the second scene's `world-position.x` to `video_width` (`1920` on a 1920-wide video) — as if `world-position` were the scene's left edge, filmstrip-style. It isn't. `1920` centers the camera on the seam between scene 1 and scene 2, framing half of each. The value that actually centers the camera on scene 2 is `video_width * 1.5` = `2880`, matching what the default formula already produces for `i = 1`. + +```json +// BAD — thinks world-position is a left-edge/origin coordinate. +// Validates cleanly (nothing checks this value), then frames the +// seam between beat 1 and beat 2 instead of beat 2's center. +{ "duration": 2.0, "world-position": { "x": 1920, "y": 540 } } + +// GOOD — world-position is the camera's CENTER waypoint for this scene. +{ "duration": 2.0, "world-position": { "x": 2880, "y": 540 } } +``` + +Nothing in `rustmotion validate` catches the bad version — it's a semantically wrong but structurally valid waypoint, not a schema or geometry error. The only way to catch it is to know the formula. + +Only set `world-position` explicitly when the path isn't a straight horizontal filmstrip: a vertical or diagonal move between beats, a zoom that revisits an earlier beat's location (see `persist` in §3), or non-uniform spacing. + +### Children use scene-local coordinates, always + +A scene's `children` are laid out exactly like a `slide`-view scene: `0..video_width` × `0..video_height`, independent of that scene's `world-position`. The camera translation is applied to the whole scene as one block, after layout — a child never needs to know where its scene sits in world space. + +This was verified against the geometry validator (`crates/rustmotion-cli/src/commands/geometry.rs`, `validate_geometry`): it checks every scene's children against `(scenario.video.width, scenario.video.height)` regardless of that scene's `world-position`. Placing a child at what looks like the world-space X (e.g. `2880` for the second beat) is rejected as viewport overflow: + +```json +// Scene 2 has "world-position": {"x": 2880, ...}. A child positioned at +// x: 2880 (thinking in world space) fails: +{ "type": "shape", "shape": "circle", "position": "absolute", "x": 2880, "y": 540, + "style": { "width": 100, "height": 100 } } +``` +``` +ERROR: shape (viewport overflow) + bbox: [2880, 540] -> [2980, 640] (viewport: 1920x1080) + hint: shift x to fit [0..1920], current right edge is 2980 +``` + +The same child at `x: 200` (scene-local) validates. Both hypotheses were tested against the actual validator output above — world-space child coordinates are rejected, scene-local coordinates are correct. + +### Casing gotcha + +`world-position` (on `Scene`) is kebab-case (`#[serde(rename = "world-position")]` in `crates/rustmotion-core/src/schema/scenario.rs`). Its neighbour on the same `Scene` struct, `freeze_at`, is snake_case with no rename at all. This is a real inconsistency in the schema, not a typo — write each field with the casing shown here, don't infer one from the other. + +--- + +## 3. The junction: `camera_pan_duration`, `camera_easing`, `persist` + +- **`camera_pan_duration`** (default `0.8`s, view-level) — how long the camera takes to glide from one scene's waypoint to the next. The pan is centered on the scene boundary: it starts `camera_pan_duration / 2` before the boundary and ends the same amount after. +- **`camera_easing`** (default `"ease_in_out"`, view-level) — easing curve for the camera's position interpolation between waypoints. +- During the pan, both the outgoing and incoming scene are visible and cross-fade (opacity ramps 1→0 / 0→1 over the pan window) while the camera moves — this is what replaces the hard cut. See `WorldTimeline::visible_scenes_at` in `world.rs`. +- **`persist`** (bool, default `false`, per-scene) — keeps a scene's content rendering (fully opaque) after its own time window ends, instead of disappearing. Combine with an explicit `world-position` that a later beat revisits, to make earlier content still be there when the camera swings back — a genuine callback, not a re-render of the same scene. +- A per-scene `transition` field is **ignored inside a `world` view** — `build_world_view_tasks` in `crates/rustmotion/src/encode/video/tasks.rs` never reads it. Continuity is entirely driven by `camera_pan_duration`/`camera_easing`; don't add `transition` objects to scenes inside a `world` view expecting them to do anything. + +**Don't confuse this with the `camera_pan` *transition type*.** `slide`-view scenes accept `"transition": { "type": "camera_pan" }`, which is a different, narrower mechanism: a two-scene jump that composites a static background from scene A with sliding foreground layers (`camera_pan_transition` in `transition.rs`), offset by the delta between the two scenes' `world-position` values. It requires both scenes to already share the same background (only scene A's background is rendered) and only ever handles one jump — it is not multi-beat continuity. Worse, at a **view boundary** (the `transition` field on a `composition` entry, used when entering/leaving a view), `camera_pan` silently falls back to a plain crossfade (`TransitionType::CameraPan => blend_fade` in `transition.rs`) — no panning at all. For an actual continuous multi-beat camera, use a `world` view, not a `camera_pan` transition. + +--- + +## 4. The ambient layer: `view.background` with `halo`, never per-scene shapes + +A `world`-view video usually wants a soft ambient glow behind everything, visible continuously through every beat and every pan. The instinct is to add a blurred `shape` circle to each scene — don't. Two things go wrong: + +1. **A large blurred glow that bleeds past scene edges (the usual placement) fails viewport validation.** Only `marquee` and `cursor` are exempt from geometry checks today (`is_exempted` in `geometry.rs`) — a decorative shape gets the same `viewport overflow` error as any content element: + ``` + ERROR: shape (viewport overflow) + bbox: [-300, -300] -> [500, 500] (viewport: 1920x1080) + hint: reposition the component to stay inside the viewport + ``` +2. **Wrapping it in `overflow: hidden` passes validation — and breaks the seamless effect.** Each `world`-view scene is painted independently into its own `video_width × video_height` block, then translated into place (`render_world_frame_scaled` in `engine/render/scene.rs`). An `overflow: hidden` container sized to the scene turns that block into a hard-edged rectangle. During a camera pan the camera straddles two scenes' blocks at once, so that edge sits in plain view mid-frame — the exact "hard cut" a `world` view exists to avoid, just wearing a disguise. + +The correct place for this layer is `view.background` with the `halo` preset — one glow layer shared by the whole view, drawn once behind every scene, continuous through every pan: + +```json +{ + "type": "world", + "background": { + "preset": "halo", + "zones": [ + { "color": "#6366F1AA", "x": 0.25, "y": 0.35, "radius": 0.55 }, + { "color": "#22D3EEAA", "x": 0.75, "y": 0.65, "radius": 0.45 } + ] + }, + "scenes": [ ... ] +} +``` + +`zones` fields: `color` (hex, may itself carry an alpha channel like `#RRGGBBAA`), `x`/`y` (fraction of width/height, default `0.5`), `radius` (fraction of `max(width, height)`, default `0.4`). This same `background` field also exists on individual scenes and at the scenario root — but for a `world` view's ambient layer, put it on the **view**, not a scene, so it doesn't disappear or restart at scene boundaries. + +--- + +## 5. Full recipe: a validated 3-beat continuous video + +Verified with `./target/release/rustmotion validate`. Demonstrates: `world-position` math for a horizontal dolly (§2), the ambient `halo` at view level (§4), and `persist` for a callback beat (§3) — beat 3 revisits beat 1's neighborhood, and beat 1's headline is still there because it persisted. + +```json +{ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30, "background": "#0a0a14" }, + "composition": [ + { + "type": "world", + "camera_pan_duration": 1.0, + "camera_easing": "ease_in_out", + "background": { + "preset": "halo", + "zones": [ + { "color": "#6366F1AA", "x": 0.25, "y": 0.35, "radius": 0.55 }, + { "color": "#22D3EEAA", "x": 0.75, "y": 0.65, "radius": 0.45 } + ] + }, + "scenes": [ + { + "duration": 2.5, + "persist": true, + "world-position": { "x": 960, "y": 540 }, + "children": [ + { "type": "text", "content": "The problem", "style": { "font-size": 72, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center", "animation": [{ "name": "fade_in_up", "duration": 0.6 }] } }, + { "type": "text", "content": "Scenes cut. Nothing survives the cut.", "style": { "font-size": 36, "color": "#94A3B8", "text-align": "center", "animation": [{ "name": "fade_in_up", "delay": 0.2, "duration": 0.6 }] } } + ] + }, + { + "duration": 2.5, + "world-position": { "x": 2880, "y": 540 }, + "children": [ + { "type": "text", "content": "The world view", "style": { "font-size": 72, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center", "animation": [{ "name": "fade_in_up", "duration": 0.6 }] } }, + { "type": "text", "content": "One camera, gliding between beats.", "style": { "font-size": 36, "color": "#94A3B8", "text-align": "center", "animation": [{ "name": "fade_in_up", "delay": 0.2, "duration": 0.6 }] } } + ] + }, + { + "duration": 2.5, + "world-position": { "x": 960, "y": 800 }, + "children": [ + { "type": "text", "content": "Full circle", "style": { "font-size": 44, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center", "animation": [{ "name": "fade_in_up", "duration": 0.6 }] } }, + { "type": "text", "content": "Beat one is still there, thanks to persist.", "style": { "font-size": 30, "color": "#94A3B8", "text-align": "center", "animation": [{ "name": "fade_in_up", "delay": 0.2, "duration": 0.6 }] } } + ] + } + ] + } + ] +} +``` + +`rustmotion validate` on this file: `Valid scenario: 3 scene(s) in 1 view(s)`. + +### Checklist + +- [ ] Composition uses `"type": "world"`, not `"slide"` +- [ ] Ambient glow, if any, is `view.background` with `preset: "halo"` — not a per-scene `shape` +- [ ] `world-position` set only where the path deviates from the default horizontal filmstrip; when set, it's the scene's **center**, computed as `video_width * (beat_index + 0.5)` for a simple sideways dolly +- [ ] Children keep scene-local coordinates (`0..video_width`, `0..video_height`) regardless of the scene's `world-position` +- [ ] No per-scene `transition` inside the `world` view — junctions are controlled by `camera_pan_duration`/`camera_easing` +- [ ] `persist: true` only on scenes meant to still be there if the camera revisits their spot later diff --git a/CLAUDE.md b/CLAUDE.md index 4d27ac1..e559b6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,16 @@ CLI : - Sans ffmpeg, le fallback openh264 intégré encode en 8-bit - Pour les vidéos avec des gradients sombres, recommander `--codec prores` pour une qualité maximale +## Composition : `scenes` vs `composition` (vues `slide` / `world`) + +Un scénario est soit une liste plate `scenes` (racine) — implicitement enveloppée dans une seule vue `slide` — soit un `composition: [...]` explicite, un tableau de **vues** typées `"slide"` ou `"world"`. Les deux sont mutuellement exclusifs (`CompositionAndScenesConflict` si les deux sont présents). + +Dans une vue `slide`, les `transition` entre scènes sont des **composites pixel de deux frame-buffers déjà rendus** (fade, wipe, zoom, flip, iris, slide…) : aucun élément ne survit à la coupe, seuls les pixels sont mélangés. + +La vue **`world`** est le seul mécanisme qui produit une continuité réelle entre beats : une caméra virtuelle se déplace en continu à travers un espace 2D où chaque scène occupe une position (`world-position`), avec un fondu de recouvrement pendant le pan au lieu d'une coupe. C'est la brique à utiliser pour une vidéo qui doit se lire comme un plan continu, sans limite de scène perceptible. Voir [rules/world-view.md](.claude/skills/rustmotion/rules/world-view.md) pour le modèle de coordonnées (le piège `world-position` = waypoint caméra, pas origine de scène), la recette du halo ambiant en `view.background`, et un exemple multi-beat validé. + +**Piège de casing à connaître :** `world-position` (scène) est en kebab-case, alors que son voisin `freeze_at` (même struct `Scene`) est en snake_case. Vraie inconsistance du schéma, pas une faute de frappe — copier la casse telle quelle. + ## Composants disponibles (57) ### Basiques From fe5d2d942d7aedbde844a68e07ba9bba525c21a1 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 1 Aug 2026 19:11:34 +0200 Subject: [PATCH 05/10] docs(examples): add the dark-premium template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second template, in the register of the Machina and Aikido reference films — deliberately not replacing the brutalist 1600-style one, which is coherent in its own language. Seven beats, 20.8s, and no beat that reads as a cut. The seamlessness rests on three things, in order of importance: camera_pan transitions with a world-position delta per scene, so the background is drawn once and stays put while foregrounds slide across it; an ambient halo layer living in the scene background rather than in shapes, since shapes would need clipping and a clip rectangle is exactly the hard edge being avoided; and a camera that never returns to where it started, so no beat is a still frame. Exercises the three features landing alongside it: char_blur_in for word reveals, halo opacity for the ambience, and bleed on the decorative orbs. Refs #117 --- examples/dark-premium.json | 1376 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1376 insertions(+) create mode 100644 examples/dark-premium.json diff --git a/examples/dark-premium.json b/examples/dark-premium.json new file mode 100644 index 0000000..09b8f52 --- /dev/null +++ b/examples/dark-premium.json @@ -0,0 +1,1376 @@ +{ + "version": "1.0", + "video": { + "width": 1920, + "height": 1080, + "fps": 30, + "background": "#06080F" + }, + "scenes": [ + { + "duration": 3.2, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E3A8A", + "x": 0.12, + "y": 0.14, + "radius": 0.26, + "opacity": 0.3 + }, + { + "color": "#E8409A", + "x": 0.88, + "y": 0.84, + "radius": 0.2, + "opacity": 0.16 + }, + { + "color": "#2A4FBF", + "x": 0.62, + "y": 0.55, + "radius": 0.16, + "opacity": 0.1 + } + ] + }, + "world-position": { + "x": 0, + "y": 0 + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#1E3A8A", + "bleed": true, + "position": "absolute", + "x": -260, + "y": -220, + "style": { + "width": 900, + "height": 900, + "opacity": 0.28, + "filter": [ + { + "fn": "blur", + "radius": 90 + } + ], + "animation": [ + { + "name": "fade_in", + "delay": 0.0, + "duration": 0.8 + }, + { + "name": "float_3d", + "loop": true, + "duration": 9.0 + } + ] + } + }, + { + "type": "text", + "content": "Vos équipes", + "position": "absolute", + "x": 190, + "y": 360, + "style": { + "font-size": 132, + "color": "#F4F6FA", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.25, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + }, + { + "type": "text", + "content": "perdent du temps", + "position": "absolute", + "x": 190, + "y": 508, + "style": { + "font-size": 132, + "color": "#7E89A3", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.62, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + } + ], + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.04 + }, + { + "time": 3.2, + "value": 1.13 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.x", + "values": [ + { + "time": 0, + "value": 960.0 + }, + { + "time": 3.2, + "value": 1020.0 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.y", + "values": [ + { + "time": 0, + "value": 540.0 + }, + { + "time": 3.2, + "value": 505.0 + } + ], + "easing": "ease_in_out" + } + ] + } + }, + { + "duration": 3.0, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E3A8A", + "x": 0.12, + "y": 0.14, + "radius": 0.26, + "opacity": 0.3 + }, + { + "color": "#E8409A", + "x": 0.88, + "y": 0.84, + "radius": 0.2, + "opacity": 0.16 + }, + { + "color": "#2A4FBF", + "x": 0.62, + "y": 0.55, + "radius": 0.16, + "opacity": 0.1 + } + ] + }, + "world-position": { + "x": 1920, + "y": 0 + }, + "children": [ + { + "type": "card", + "position": "absolute", + "x": 120, + "y": 180, + "style": { + "width": 620, + "height": 210, + "background": "#121A2B", + "border-radius": 18, + "border": { + "width": 1, + "color": "#243350" + }, + "transform": [ + { + "fn": "rotate", + "deg": -7 + } + ], + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 34, + "blur": 80, + "color": "#000000BB" + } + ], + "animation": [ + { + "name": "fade_in", + "delay": 0.15, + "duration": 0.5 + }, + { + "name": "float_3d", + "loop": true, + "duration": 8.0 + } + ] + } + }, + { + "type": "card", + "position": "absolute", + "x": 820, + "y": 300, + "style": { + "width": 620, + "height": 210, + "background": "#121A2B", + "border-radius": 18, + "border": { + "width": 1, + "color": "#243350" + }, + "transform": [ + { + "fn": "rotate", + "deg": 4 + } + ], + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 34, + "blur": 80, + "color": "#000000BB" + } + ], + "animation": [ + { + "name": "fade_in", + "delay": 0.3, + "duration": 0.5 + }, + { + "name": "float_3d", + "loop": true, + "duration": 8.0 + } + ] + } + }, + { + "type": "card", + "position": "absolute", + "x": 330, + "y": 470, + "style": { + "width": 620, + "height": 210, + "background": "#121A2B", + "border-radius": 18, + "border": { + "width": 1, + "color": "#243350" + }, + "transform": [ + { + "fn": "rotate", + "deg": 6 + } + ], + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 34, + "blur": 80, + "color": "#000000BB" + } + ], + "animation": [ + { + "name": "fade_in", + "delay": 0.45, + "duration": 0.5 + }, + { + "name": "float_3d", + "loop": true, + "duration": 8.0 + } + ] + } + }, + { + "type": "text", + "content": "Douze outils.", + "position": "absolute", + "x": 190, + "y": 760, + "style": { + "font-size": 92, + "color": "#7E89A3", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.75, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + }, + { + "type": "text", + "content": "Aucun qui parle aux autres.", + "position": "absolute", + "x": 190, + "y": 860, + "style": { + "font-size": 92, + "color": "#7E89A3", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.95, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + } + ], + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.12 + }, + { + "time": 3.0, + "value": 1.02 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.x", + "values": [ + { + "time": 0, + "value": 960.0 + }, + { + "time": 3.0, + "value": 890.0 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.y", + "values": [ + { + "time": 0, + "value": 540.0 + }, + { + "time": 3.0, + "value": 505.0 + } + ], + "easing": "ease_in_out" + } + ] + }, + "transition": { + "type": "camera_pan", + "duration": 0.75, + "easing": "ease_in_out_expo" + } + }, + { + "duration": 2.8, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E3A8A", + "x": 0.12, + "y": 0.14, + "radius": 0.26, + "opacity": 0.3 + }, + { + "color": "#E8409A", + "x": 0.88, + "y": 0.84, + "radius": 0.2, + "opacity": 0.16 + }, + { + "color": "#2A4FBF", + "x": 0.62, + "y": 0.55, + "radius": 0.16, + "opacity": 0.1 + } + ] + }, + "world-position": { + "x": 1920, + "y": 1080 + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#E8409A", + "bleed": true, + "position": "absolute", + "x": 1500, + "y": 700, + "style": { + "width": 950, + "height": 950, + "opacity": 0.28, + "filter": [ + { + "fn": "blur", + "radius": 90 + } + ], + "animation": [ + { + "name": "fade_in", + "delay": 0.1, + "duration": 0.8 + }, + { + "name": "float_3d", + "loop": true, + "duration": 9.0 + } + ] + } + }, + { + "type": "text", + "content": "Une seule", + "position": "absolute", + "x": 190, + "y": 380, + "style": { + "font-size": 168, + "color": "#F4F6FA", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.2, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + }, + { + "type": "text", + "content": "plateforme", + "position": "absolute", + "x": 190, + "y": 570, + "style": { + "font-size": 168, + "color": "#5A8CFF", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.5, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + } + ], + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.0 + }, + { + "time": 2.8, + "value": 1.12 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.x", + "values": [ + { + "time": 0, + "value": 960.0 + }, + { + "time": 2.8, + "value": 1010.0 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.y", + "values": [ + { + "time": 0, + "value": 540.0 + }, + { + "time": 2.8, + "value": 580.0 + } + ], + "easing": "ease_in_out" + } + ] + }, + "transition": { + "type": "camera_pan", + "duration": 0.75, + "easing": "ease_in_out_expo" + } + }, + { + "duration": 3.2, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E3A8A", + "x": 0.12, + "y": 0.14, + "radius": 0.26, + "opacity": 0.3 + }, + { + "color": "#E8409A", + "x": 0.88, + "y": 0.84, + "radius": 0.2, + "opacity": 0.16 + }, + { + "color": "#2A4FBF", + "x": 0.62, + "y": 0.55, + "radius": 0.16, + "opacity": 0.1 + } + ] + }, + "world-position": { + "x": 3840, + "y": 1080 + }, + "children": [ + { + "type": "card", + "position": "absolute", + "x": 420, + "y": 190, + "style": { + "width": 1100, + "height": 620, + "background": "#1A2438", + "border-radius": 20, + "border": { + "width": 1, + "color": "#243350" + }, + "perspective": 1400, + "transform": [ + { + "fn": "rotate-y", + "deg": -16 + }, + { + "fn": "rotate-x", + "deg": 6 + } + ], + "box-shadow": [ + { + "offset-x": 0, + "offset-y": 50, + "blur": 110, + "color": "#000000CC" + } + ], + "animation": [ + { + "name": "fade_in", + "delay": 0.15, + "duration": 0.6 + }, + { + "name": "float_3d", + "loop": true, + "duration": 9.0 + } + ] + }, + "children": [ + { + "type": "text", + "content": "Tout au même endroit", + "position": "absolute", + "x": 70, + "y": 70, + "style": { + "font-size": 72, + "color": "#F4F6FA", + "width": 940 + } + }, + { + "type": "text", + "content": "Alertes · Conformité · Correctifs", + "position": "absolute", + "x": 70, + "y": 160, + "style": { + "font-size": 46, + "color": "#7E89A3", + "width": 940 + } + } + ] + }, + { + "type": "text", + "content": "De l'alerte au correctif", + "position": "absolute", + "x": 190, + "y": 880, + "style": { + "font-size": 76, + "color": "#7E89A3", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.85, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + } + ], + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.1 + }, + { + "time": 3.2, + "value": 1.0 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.x", + "values": [ + { + "time": 0, + "value": 960.0 + }, + { + "time": 3.2, + "value": 905.0 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.y", + "values": [ + { + "time": 0, + "value": 540.0 + }, + { + "time": 3.2, + "value": 510.0 + } + ], + "easing": "ease_in_out" + } + ] + }, + "transition": { + "type": "camera_pan", + "duration": 0.75, + "easing": "ease_in_out_expo" + } + }, + { + "duration": 2.8, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E3A8A", + "x": 0.12, + "y": 0.14, + "radius": 0.26, + "opacity": 0.3 + }, + { + "color": "#E8409A", + "x": 0.88, + "y": 0.84, + "radius": 0.2, + "opacity": 0.16 + }, + { + "color": "#2A4FBF", + "x": 0.62, + "y": 0.55, + "radius": 0.16, + "opacity": 0.1 + } + ] + }, + "world-position": { + "x": 5760, + "y": 1080 + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#E8409A", + "bleed": true, + "position": "absolute", + "x": 1620, + "y": 760, + "style": { + "width": 1000, + "height": 1000, + "opacity": 0.28, + "filter": [ + { + "fn": "blur", + "radius": 90 + } + ], + "animation": [ + { + "name": "fade_in", + "delay": 0.05, + "duration": 0.8 + }, + { + "name": "float_3d", + "loop": true, + "duration": 9.0 + } + ] + } + }, + { + "type": "text", + "content": "-85%", + "position": "absolute", + "x": 190, + "y": 300, + "style": { + "font-size": 320, + "color": "#5A8CFF", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.15, + "stagger": 0.0, + "duration": 0.6, + "easing": "ease_out_expo", + "blur": 26 + } + ] + } + }, + { + "type": "text", + "content": "d'alertes inutiles", + "position": "absolute", + "x": 190, + "y": 660, + "style": { + "font-size": 84, + "color": "#7E89A3", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.55, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + } + ], + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.0 + }, + { + "time": 2.8, + "value": 1.14 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.x", + "values": [ + { + "time": 0, + "value": 960.0 + }, + { + "time": 2.8, + "value": 1020.0 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.y", + "values": [ + { + "time": 0, + "value": 540.0 + }, + { + "time": 2.8, + "value": 505.0 + } + ], + "easing": "ease_in_out" + } + ] + }, + "transition": { + "type": "camera_pan", + "duration": 0.75, + "easing": "ease_in_out_expo" + } + }, + { + "duration": 2.8, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E3A8A", + "x": 0.12, + "y": 0.14, + "radius": 0.26, + "opacity": 0.3 + }, + { + "color": "#E8409A", + "x": 0.88, + "y": 0.84, + "radius": 0.2, + "opacity": 0.16 + }, + { + "color": "#2A4FBF", + "x": 0.62, + "y": 0.55, + "radius": 0.16, + "opacity": 0.1 + } + ] + }, + "world-position": { + "x": 5760, + "y": 0 + }, + "children": [ + { + "type": "text", + "content": "Ce que ça change", + "position": "absolute", + "x": 190, + "y": 260, + "style": { + "font-size": 96, + "color": "#F4F6FA", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.2, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + }, + { + "type": "badge", + "text": "ISO 27001", + "position": "absolute", + "x": 190, + "y": 470, + "style": { + "font-size": 44, + "color": "#5A8CFF", + "background": "#111C2E", + "border": { + "width": 1, + "color": "#243350" + }, + "border-radius": 999, + "padding": { + "top": 20, + "bottom": 20, + "left": 40, + "right": 40 + }, + "animation": [ + { + "name": "scale_in", + "delay": 0.55, + "duration": 0.45 + } + ] + } + }, + { + "type": "badge", + "text": "SOC 2", + "position": "absolute", + "x": 590, + "y": 470, + "style": { + "font-size": 44, + "color": "#5A8CFF", + "background": "#111C2E", + "border": { + "width": 1, + "color": "#243350" + }, + "border-radius": 999, + "padding": { + "top": 20, + "bottom": 20, + "left": 40, + "right": 40 + }, + "animation": [ + { + "name": "scale_in", + "delay": 0.68, + "duration": 0.45 + } + ] + } + }, + { + "type": "badge", + "text": "RGPD", + "position": "absolute", + "x": 900, + "y": 470, + "style": { + "font-size": 44, + "color": "#5A8CFF", + "background": "#111C2E", + "border": { + "width": 1, + "color": "#243350" + }, + "border-radius": 999, + "padding": { + "top": 20, + "bottom": 20, + "left": 40, + "right": 40 + }, + "animation": [ + { + "name": "scale_in", + "delay": 0.81, + "duration": 0.45 + } + ] + } + }, + { + "type": "badge", + "text": "Déployé en 30 jours", + "position": "absolute", + "x": 190, + "y": 630, + "style": { + "font-size": 44, + "color": "#E8409A", + "background": "#111C2E", + "border": { + "width": 1, + "color": "#243350" + }, + "border-radius": 999, + "padding": { + "top": 20, + "bottom": 20, + "left": 40, + "right": 40 + }, + "animation": [ + { + "name": "scale_in", + "delay": 0.94, + "duration": 0.45 + } + ] + } + } + ], + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.12 + }, + { + "time": 2.8, + "value": 1.02 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.x", + "values": [ + { + "time": 0, + "value": 960.0 + }, + { + "time": 2.8, + "value": 1020.0 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.y", + "values": [ + { + "time": 0, + "value": 540.0 + }, + { + "time": 2.8, + "value": 585.0 + } + ], + "easing": "ease_in_out" + } + ] + }, + "transition": { + "type": "camera_pan", + "duration": 0.75, + "easing": "ease_in_out_expo" + } + }, + { + "duration": 3.0, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E3A8A", + "x": 0.12, + "y": 0.14, + "radius": 0.26, + "opacity": 0.3 + }, + { + "color": "#E8409A", + "x": 0.88, + "y": 0.84, + "radius": 0.2, + "opacity": 0.16 + }, + { + "color": "#2A4FBF", + "x": 0.62, + "y": 0.55, + "radius": 0.16, + "opacity": 0.1 + } + ] + }, + "world-position": { + "x": 7680, + "y": 0 + }, + "children": [ + { + "type": "shape", + "shape": "circle", + "fill": "#1E3A8A", + "bleed": true, + "position": "absolute", + "x": -200, + "y": -160, + "style": { + "width": 950, + "height": 950, + "opacity": 0.28, + "filter": [ + { + "fn": "blur", + "radius": 90 + } + ], + "animation": [ + { + "name": "fade_in", + "delay": 0.0, + "duration": 0.8 + }, + { + "name": "float_3d", + "loop": true, + "duration": 9.0 + } + ] + } + }, + { + "type": "shape", + "shape": "circle", + "fill": "#E8409A", + "bleed": true, + "position": "absolute", + "x": 1650, + "y": 820, + "style": { + "width": 800, + "height": 800, + "opacity": 0.28, + "filter": [ + { + "fn": "blur", + "radius": 90 + } + ], + "animation": [ + { + "name": "fade_in", + "delay": 0.2, + "duration": 0.8 + }, + { + "name": "float_3d", + "loop": true, + "duration": 9.0 + } + ] + } + }, + { + "type": "text", + "content": "On commence ?", + "position": "absolute", + "x": 190, + "y": 430, + "style": { + "font-size": 152, + "color": "#F4F6FA", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.25, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + }, + { + "type": "text", + "content": "machina.io", + "position": "absolute", + "x": 190, + "y": 620, + "style": { + "font-size": 64, + "color": "#5A8CFF", + "line-height": 1.04, + "letter-spacing": -2, + "width": 1480, + "animation": [ + { + "name": "char_blur_in", + "granularity": "word", + "delay": 0.7, + "stagger": 0.07, + "duration": 0.6, + "easing": "ease_out_expo" + } + ] + } + } + ], + "camera": { + "keyframes": [ + { + "property": "zoom", + "values": [ + { + "time": 0, + "value": 1.1 + }, + { + "time": 3.0, + "value": 1.01 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.x", + "values": [ + { + "time": 0, + "value": 960.0 + }, + { + "time": 3.0, + "value": 915.0 + } + ], + "easing": "ease_in_out" + }, + { + "property": "origin.y", + "values": [ + { + "time": 0, + "value": 540.0 + }, + { + "time": 3.0, + "value": 505.0 + } + ], + "easing": "ease_in_out" + } + ] + }, + "transition": { + "type": "camera_pan", + "duration": 0.75, + "easing": "ease_in_out_expo" + } + } + ] +} From 2ac9ebf701a812a0261ee663ffab4dc9447353ed Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 2 Aug 2026 01:03:34 +0200 Subject: [PATCH 06/10] fix(background): stop the halo preset from strobing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each halo zone breathes: its radius is scaled by 1 + 0.15*sin(time*freq). `freq` came straight from `speed`, which is shared with the scrolling presets where it means pixels per second and defaults to 30. Used as an angular frequency that is 30 rad/s — a period of 0.16 to 0.30 seconds, so the ambience pulsed at roughly 5 Hz. It read as flicker, not as light, and on a dark gradient it was the most visible thing in the frame. Convert the scroll speed into a breathing rate instead. At the default it now gives a period of 8 to 15 seconds — about one cycle over a short video, which reads as the glow being alive rather than broken. Measured on the dark background of examples/dark-premium.json, mean absolute difference between consecutive frames: 2.04 before, 0.71 after, while the slow luminance swing that carries the breathing is preserved (8.8 vs 9.3). --- crates/rustmotion/src/engine/render/background.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index fcb929c..c507947 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -204,10 +204,16 @@ fn draw_bg_halo(canvas: &Canvas, cfg: &HaloConfig, speed: f32, time: f32, width: let cx = zone.x * width; let cy = zone.y * height; let base_radius = zone.radius * width.max(height); - // Each particle gets a unique phase and slightly different frequency + // Each zone gets a unique phase and slightly different frequency. let phase = (zone.x * 17.3 + zone.y * 31.7 + i as f32 * 0.73).fract() * std::f32::consts::TAU; - let freq = speed * (0.7 + (zone.x * 13.1 + zone.y * 7.9).fract() * 0.6); + // `speed` is shared with the scrolling presets, where it means pixels + // per second and defaults to 30. Used directly as an angular frequency + // that is 30 rad/s — a ~5 Hz strobe, not a glow. BREATH_RATE converts + // it into a slow ambient pulse: at the default it gives a period of + // roughly 10 seconds, which reads as light rather than as flicker. + const BREATH_RATE: f32 = 0.02; + let freq = speed * BREATH_RATE * (0.7 + (zone.x * 13.1 + zone.y * 7.9).fract() * 0.6); let breath = 1.0 + 0.15 * (time * freq + phase).sin(); let radius = base_radius * breath; From 6ed3e0d30a9bf88ca57093799709be63633641c1 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 2 Aug 2026 01:03:34 +0200 Subject: [PATCH 07/10] feat(transition): let a camera pan carry each scene's own background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A camera pan drew one background — the outgoing scene's — as a fixed backdrop, and slid only the foregrounds over it. That is what makes a shared ambience survive a junction, and it is the right default: it is precisely why a multi-beat video reads as a single shot rather than a sequence of cuts. But it also made a per-beat background impossible. A scene whose ambience differed from its predecessor's never showed it during the pan; the new background appeared only once the transition had finished, as a pop. `transition.background` now chooses. `static` keeps the current behaviour and stays the default, so no existing scenario changes. `travel` renders both backgrounds and locks each to its own foreground, so a beat moves as one plane and the whole world changes at the junction rather than just the words. The second background is only rendered when it will actually be used. --- .../rustmotion-core/src/engine/transition.rs | 47 ++++++++++++++----- crates/rustmotion-core/src/schema/scenario.rs | 22 +++++++++ crates/rustmotion/src/encode/video/tasks.rs | 23 ++++++++- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 541bcf3..5703794 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -380,8 +380,17 @@ fn dissolve_transition( /// Camera pan transition: static background + sliding foreground children. /// `bg` is the static background, `fg_a`/`fg_b` are children-only (transparent). /// fg_a slides out by (-dx*t, -dy*t), fg_b slides in from (dx*(1-t), dy*(1-t)). +/// Composite a camera pan between two scenes. +/// +/// `bg_b` is `None` when the backgrounds stay put: `bg_a` is then drawn once as +/// a fixed backdrop and only the foregrounds slide, which is what keeps a +/// shared ambience continuous across a beat. When `Some`, each background +/// travels locked to its own foreground, so the two beats read as different +/// places rather than one space. +#[allow(clippy::too_many_arguments)] pub fn camera_pan_transition( - bg: &[u8], + bg_a: &[u8], + bg_b: Option<&[u8]>, fg_a: &[u8], fg_b: &[u8], width: u32, @@ -395,30 +404,44 @@ pub fn camera_pan_transition( let mut surface = match create_skia_surface(width, height) { Some(s) => s, - None => return bg.to_vec(), + None => return bg_a.to_vec(), }; - let img_bg = match frame_to_image(bg, width, height) { + let img_bg_a = match frame_to_image(bg_a, width, height) { Some(i) => i, - None => return bg.to_vec(), + None => return bg_a.to_vec(), }; let img_fg_a = match frame_to_image(fg_a, width, height) { Some(i) => i, - None => return bg.to_vec(), + None => return bg_a.to_vec(), }; let img_fg_b = match frame_to_image(fg_b, width, height) { Some(i) => i, - None => return bg.to_vec(), + None => return bg_a.to_vec(), }; let canvas = surface.canvas(); - // Static background - canvas.draw_image(&img_bg, (0.0, 0.0), None); + // Offsets: the outgoing plane exits, the incoming one arrives. They tile + // exactly, so together they always cover the frame. + let (out_x, out_y) = (-dx * t, -dy * t); + let (in_x, in_y) = (dx * (1.0 - t), dy * (1.0 - t)); + + match bg_b.and_then(|b| frame_to_image(b, width, height)) { + // Travelling: each background is locked to its own foreground, so a + // beat moves as one plane. + Some(img_bg_b) => { + canvas.draw_image(&img_bg_a, (out_x, out_y), None); + canvas.draw_image(&img_bg_b, (in_x, in_y), None); + } + // Fixed backdrop: drawn once at rest, so a shared ambience stays + // continuous and the join is invisible. + None => { + canvas.draw_image(&img_bg_a, (0.0, 0.0), None); + } + } - // fg_a slides out - canvas.draw_image(&img_fg_a, (-dx * t, -dy * t), None); - // fg_b slides in - canvas.draw_image(&img_fg_b, (dx * (1.0 - t), dy * (1.0 - t)), None); + canvas.draw_image(&img_fg_a, (out_x, out_y), None); + canvas.draw_image(&img_fg_b, (in_x, in_y), None); surface_to_pixels(surface, width, height) } diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 64f3813..119db03 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -550,6 +550,28 @@ pub struct Transition { pub duration: f64, #[serde(default = "default_transition_easing")] pub easing: EasingType, + /// How the background behaves during a `camera_pan`. Ignored by every + /// other transition type, which composite two finished frames and have no + /// separate background layer to move. + #[serde(default)] + pub background: PanBackground, +} + +/// Whether a `camera_pan` treats the background as a fixed backdrop the scenes +/// slide across, or as part of each scene, travelling with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PanBackground { + /// The outgoing scene's background stays put while both foregrounds slide + /// over it. Keeps a shared ambience continuous, so the cut is invisible — + /// this is the default because it is what makes a multi-beat video read as + /// one shot. + #[default] + Static, + /// Each scene carries its own background, and both travel with their + /// foreground. Use this when the beats are meant to look like different + /// places rather than one continuous space. + Travel, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] diff --git a/crates/rustmotion/src/encode/video/tasks.rs b/crates/rustmotion/src/encode/video/tasks.rs index 45490e1..5f86e35 100644 --- a/crates/rustmotion/src/encode/video/tasks.rs +++ b/crates/rustmotion/src/encode/video/tasks.rs @@ -1,8 +1,8 @@ use crate::engine::transition::{apply_transition, camera_pan_transition}; use crate::error::Result; use crate::schema::{ - EasingType, ResolvedScenario as Scenario, ResolvedView, Scene, TransitionType, VideoConfig, - ViewType, + EasingType, PanBackground, ResolvedScenario as Scenario, ResolvedView, Scene, TransitionType, + VideoConfig, ViewType, }; /// Description of what to render for a specific frame @@ -154,6 +154,24 @@ pub fn render_frame_task_scaled( frame_a_idx, scale_factor, )?; + // The transition belongs to the scene being entered, so that is + // where the background mode lives. + let pan_bg = scenes[*scene_b_idx] + .transition + .as_ref() + .map(|t| t.background) + .unwrap_or_default(); + // Only rendered when the backgrounds travel — otherwise scene A's + // is a fixed backdrop and rendering B's would be wasted work. + let bg_b = match pan_bg { + PanBackground::Travel => Some(render_scene_bg_scaled( + config, + &scenes[*scene_b_idx], + *frame_in_transition, + scale_factor, + )?), + PanBackground::Static => None, + }; let fg_a = render_scene_fg_scaled( config, &scenes[*scene_a_idx], @@ -171,6 +189,7 @@ pub fn render_frame_task_scaled( // CameraPan composites two scenes; apply scene_b effects to the composited result. let mut composited = camera_pan_transition( &bg, + bg_b.as_deref(), &fg_a, &fg_b, scaled_w, From 295bf7a14defc0a48b902b2bfb66dde4628c6158 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 2 Aug 2026 01:03:34 +0200 Subject: [PATCH 08/10] docs(examples): give each beat of dark-premium its own ambience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template shared one halo layer across all seven beats. That was the safe choice while a pan could only hold a fixed backdrop, but it left the ambience inert: the words changed, the world did not. Each beat now carries its own palette and the pans use `background: travel`, so the ambience arrives with the scene. The progression follows the argument — cold and thin while the problem is stated, darkest and most crowded at the beat about too many tools, opening into blue as it turns, resolving on magenta for the number and on both accents at the call to action. Also drops the decorative orbs. They were foreground children, and during a pan the two foreground planes are adjacent rather than overlapping, so ambience living there breaks at the join. All of it belongs in the background — which is what this template's own recipe says, and what it was not doing. --- examples/dark-premium.json | 342 ++++++++----------------------------- 1 file changed, 73 insertions(+), 269 deletions(-) diff --git a/examples/dark-premium.json b/examples/dark-premium.json index 09b8f52..f9685da 100644 --- a/examples/dark-premium.json +++ b/examples/dark-premium.json @@ -14,23 +14,16 @@ "zones": [ { "color": "#1E3A8A", - "x": 0.12, - "y": 0.14, + "x": 0.14, + "y": 0.16, "radius": 0.26, "opacity": 0.3 }, - { - "color": "#E8409A", - "x": 0.88, - "y": 0.84, - "radius": 0.2, - "opacity": 0.16 - }, { "color": "#2A4FBF", - "x": 0.62, - "y": 0.55, - "radius": 0.16, + "x": 0.78, + "y": 0.72, + "radius": 0.18, "opacity": 0.1 } ] @@ -40,38 +33,6 @@ "y": 0 }, "children": [ - { - "type": "shape", - "shape": "circle", - "fill": "#1E3A8A", - "bleed": true, - "position": "absolute", - "x": -260, - "y": -220, - "style": { - "width": 900, - "height": 900, - "opacity": 0.28, - "filter": [ - { - "fn": "blur", - "radius": 90 - } - ], - "animation": [ - { - "name": "fade_in", - "delay": 0.0, - "duration": 0.8 - }, - { - "name": "float_3d", - "loop": true, - "duration": 9.0 - } - ] - } - }, { "type": "text", "content": "Vos équipes", @@ -174,25 +135,25 @@ "preset": "halo", "zones": [ { - "color": "#1E3A8A", - "x": 0.12, - "y": 0.14, - "radius": 0.26, - "opacity": 0.3 + "color": "#3A1E6E", + "x": 0.22, + "y": 0.2, + "radius": 0.22, + "opacity": 0.26 }, { - "color": "#E8409A", - "x": 0.88, - "y": 0.84, + "color": "#1E3A8A", + "x": 0.72, + "y": 0.68, "radius": 0.2, - "opacity": 0.16 + "opacity": 0.18 }, { - "color": "#2A4FBF", - "x": 0.62, - "y": 0.55, + "color": "#5B2A8A", + "x": 0.52, + "y": 0.42, "radius": 0.16, - "opacity": 0.1 + "opacity": 0.12 } ] }, @@ -425,7 +386,8 @@ "transition": { "type": "camera_pan", "duration": 0.75, - "easing": "ease_in_out_expo" + "easing": "ease_in_out_expo", + "background": "travel" } }, { @@ -434,25 +396,18 @@ "preset": "halo", "zones": [ { - "color": "#1E3A8A", - "x": 0.12, - "y": 0.14, - "radius": 0.26, - "opacity": 0.3 + "color": "#1E4FBF", + "x": 0.18, + "y": 0.22, + "radius": 0.3, + "opacity": 0.38 }, { - "color": "#E8409A", - "x": 0.88, - "y": 0.84, - "radius": 0.2, + "color": "#5A8CFF", + "x": 0.8, + "y": 0.7, + "radius": 0.22, "opacity": 0.16 - }, - { - "color": "#2A4FBF", - "x": 0.62, - "y": 0.55, - "radius": 0.16, - "opacity": 0.1 } ] }, @@ -461,38 +416,6 @@ "y": 1080 }, "children": [ - { - "type": "shape", - "shape": "circle", - "fill": "#E8409A", - "bleed": true, - "position": "absolute", - "x": 1500, - "y": 700, - "style": { - "width": 950, - "height": 950, - "opacity": 0.28, - "filter": [ - { - "fn": "blur", - "radius": 90 - } - ], - "animation": [ - { - "name": "fade_in", - "delay": 0.1, - "duration": 0.8 - }, - { - "name": "float_3d", - "loop": true, - "duration": 9.0 - } - ] - } - }, { "type": "text", "content": "Une seule", @@ -591,7 +514,8 @@ "transition": { "type": "camera_pan", "duration": 0.75, - "easing": "ease_in_out_expo" + "easing": "ease_in_out_expo", + "background": "travel" } }, { @@ -600,25 +524,18 @@ "preset": "halo", "zones": [ { - "color": "#1E3A8A", + "color": "#12326E", "x": 0.12, - "y": 0.14, - "radius": 0.26, - "opacity": 0.3 + "y": 0.18, + "radius": 0.28, + "opacity": 0.28 }, { - "color": "#E8409A", - "x": 0.88, - "y": 0.84, + "color": "#2F6BCF", + "x": 0.84, + "y": 0.76, "radius": 0.2, - "opacity": 0.16 - }, - { - "color": "#2A4FBF", - "x": 0.62, - "y": 0.55, - "radius": 0.16, - "opacity": 0.1 + "opacity": 0.14 } ] }, @@ -774,7 +691,8 @@ "transition": { "type": "camera_pan", "duration": 0.75, - "easing": "ease_in_out_expo" + "easing": "ease_in_out_expo", + "background": "travel" } }, { @@ -783,25 +701,18 @@ "preset": "halo", "zones": [ { - "color": "#1E3A8A", - "x": 0.12, - "y": 0.14, - "radius": 0.26, - "opacity": 0.3 + "color": "#7A1A55", + "x": 0.8, + "y": 0.72, + "radius": 0.28, + "opacity": 0.34 }, { - "color": "#E8409A", - "x": 0.88, - "y": 0.84, - "radius": 0.2, - "opacity": 0.16 - }, - { - "color": "#2A4FBF", - "x": 0.62, - "y": 0.55, - "radius": 0.16, - "opacity": 0.1 + "color": "#1E3A8A", + "x": 0.16, + "y": 0.24, + "radius": 0.22, + "opacity": 0.2 } ] }, @@ -810,38 +721,6 @@ "y": 1080 }, "children": [ - { - "type": "shape", - "shape": "circle", - "fill": "#E8409A", - "bleed": true, - "position": "absolute", - "x": 1620, - "y": 760, - "style": { - "width": 1000, - "height": 1000, - "opacity": 0.28, - "filter": [ - { - "fn": "blur", - "radius": 90 - } - ], - "animation": [ - { - "name": "fade_in", - "delay": 0.05, - "duration": 0.8 - }, - { - "name": "float_3d", - "loop": true, - "duration": 9.0 - } - ] - } - }, { "type": "text", "content": "-85%", @@ -941,7 +820,8 @@ "transition": { "type": "camera_pan", "duration": 0.75, - "easing": "ease_in_out_expo" + "easing": "ease_in_out_expo", + "background": "travel" } }, { @@ -950,25 +830,18 @@ "preset": "halo", "zones": [ { - "color": "#1E3A8A", - "x": 0.12, - "y": 0.14, + "color": "#16386E", + "x": 0.2, + "y": 0.24, "radius": 0.26, - "opacity": 0.3 + "opacity": 0.26 }, { - "color": "#E8409A", - "x": 0.88, - "y": 0.84, + "color": "#8A2A66", + "x": 0.82, + "y": 0.74, "radius": 0.2, - "opacity": 0.16 - }, - { - "color": "#2A4FBF", - "x": 0.62, - "y": 0.55, - "radius": 0.16, - "opacity": 0.1 + "opacity": 0.18 } ] }, @@ -1171,7 +1044,8 @@ "transition": { "type": "camera_pan", "duration": 0.75, - "easing": "ease_in_out_expo" + "easing": "ease_in_out_expo", + "background": "travel" } }, { @@ -1181,24 +1055,17 @@ "zones": [ { "color": "#1E3A8A", - "x": 0.12, - "y": 0.14, - "radius": 0.26, - "opacity": 0.3 + "x": 0.14, + "y": 0.18, + "radius": 0.28, + "opacity": 0.34 }, { "color": "#E8409A", - "x": 0.88, - "y": 0.84, - "radius": 0.2, - "opacity": 0.16 - }, - { - "color": "#2A4FBF", - "x": 0.62, - "y": 0.55, - "radius": 0.16, - "opacity": 0.1 + "x": 0.84, + "y": 0.8, + "radius": 0.24, + "opacity": 0.24 } ] }, @@ -1207,70 +1074,6 @@ "y": 0 }, "children": [ - { - "type": "shape", - "shape": "circle", - "fill": "#1E3A8A", - "bleed": true, - "position": "absolute", - "x": -200, - "y": -160, - "style": { - "width": 950, - "height": 950, - "opacity": 0.28, - "filter": [ - { - "fn": "blur", - "radius": 90 - } - ], - "animation": [ - { - "name": "fade_in", - "delay": 0.0, - "duration": 0.8 - }, - { - "name": "float_3d", - "loop": true, - "duration": 9.0 - } - ] - } - }, - { - "type": "shape", - "shape": "circle", - "fill": "#E8409A", - "bleed": true, - "position": "absolute", - "x": 1650, - "y": 820, - "style": { - "width": 800, - "height": 800, - "opacity": 0.28, - "filter": [ - { - "fn": "blur", - "radius": 90 - } - ], - "animation": [ - { - "name": "fade_in", - "delay": 0.2, - "duration": 0.8 - }, - { - "name": "float_3d", - "loop": true, - "duration": 9.0 - } - ] - } - }, { "type": "text", "content": "On commence ?", @@ -1369,7 +1172,8 @@ "transition": { "type": "camera_pan", "duration": 0.75, - "easing": "ease_in_out_expo" + "easing": "ease_in_out_expo", + "background": "travel" } } ] From 0c50c0030caec79e190e7e000a78cd4b40528237 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 2 Aug 2026 01:13:36 +0200 Subject: [PATCH 09/10] fix(transition): dissolve travelling backgrounds instead of butting them together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `background: travel` slid each background locked to its foreground, at the same distance. The two are opaque full-frame images that tile exactly, so they met on a line — and a line between two different ambiences is a scene boundary, which is the one thing this transition exists to avoid. It was clearly visible whenever consecutive beats had different palettes. Three changes, each needed for the others to work: Backgrounds now drift at a fraction of the foreground's distance. That is how parallax behaves — what is far away moves less — and it makes them overlap across nearly the whole frame rather than sharing an edge. They are drawn overscaled by the largest drift either can take. Translating an opaque image uncovers a strip on the opposite side, and that strip reads as a hard edge exactly as a join would; the overscale means neither ever exposes one. The outgoing background stays opaque and the incoming one dissolves over it. Fading both leaves the frame under-covered wherever only one of them reaches, which trades the seam for a dark band — measurably worse than the seam it replaced. Largest adjacent-column luminance step across the background of examples/dark-premium.json: 7.74 before, 5.48 after, and what remains sits mid -frame on a gradient rather than on a layer edge. --- .../rustmotion-core/src/engine/transition.rs | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 5703794..753b474 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -427,11 +427,41 @@ pub fn camera_pan_transition( let (in_x, in_y) = (dx * (1.0 - t), dy * (1.0 - t)); match bg_b.and_then(|b| frame_to_image(b, width, height)) { - // Travelling: each background is locked to its own foreground, so a - // beat moves as one plane. + // Travelling: each background moves with its own scene, but at a + // fraction of the foreground's distance and fading across the pan. + // + // Two reasons for the fraction. It is how parallax actually works — + // what is far away moves less — and it makes the two backgrounds + // overlap across most of the frame instead of meeting edge to edge. + // Opaque images laid side by side join on a hard line no crossfade can + // hide; overlapping ones dissolve into each other. Some(img_bg_b) => { - canvas.draw_image(&img_bg_a, (out_x, out_y), None); - canvas.draw_image(&img_bg_b, (in_x, in_y), None); + // Backgrounds drift at a fraction of the foreground's distance — + // that is how parallax works, and it keeps them overlapping + // instead of meeting edge to edge, where two opaque images join on + // a line no fade can hide. + const BG_PARALLAX: f32 = 0.12; + let (bax, bay) = (out_x * BG_PARALLAX, out_y * BG_PARALLAX); + let (bbx, bby) = (in_x * BG_PARALLAX, in_y * BG_PARALLAX); + + // Translating an opaque image uncovers a strip on the opposite + // side, and that strip reads as a hard edge just as much as a + // join would. Overscaling by the largest drift either background + // can take means neither ever exposes one. + let mx = (dx * BG_PARALLAX).abs(); + let my = (dy * BG_PARALLAX).abs(); + let w = width as f32; + let h = height as f32; + let spread = + |ox: f32, oy: f32| Rect::from_ltrb(-mx + ox, -my + oy, w + mx + ox, h + my + oy); + + // The outgoing background stays opaque so the frame is always + // covered; the incoming one dissolves over it. Fading both would + // darken wherever only one of them reaches. + canvas.draw_image_rect(&img_bg_a, None, spread(bax, bay), &Paint::default()); + let mut incoming = Paint::default(); + incoming.set_alpha_f(t); + canvas.draw_image_rect(&img_bg_b, None, spread(bbx, bby), &incoming); } // Fixed backdrop: drawn once at rest, so a shared ambience stays // continuous and the join is invisible. From 84c97785257e1a619e9d1eb49ed254a06c03f470 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 2 Aug 2026 01:20:31 +0200 Subject: [PATCH 10/10] fix(transition): size the pan overscale to the actual drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each travelling background was overscaled by the largest drift it could take, which made the margin constant across the whole transition — including at both ends, where the displacement is zero. The background therefore switched between a normal frame and an enlarged one at every junction. Tie the margin to each layer's current offset instead: just enough to cover the strip its own translation uncovers, and exactly zero where a transition meets a normal frame. This is correct rather than dramatic. Measured on the background of examples/dark-premium.json, peak frame-to-frame change went from 10.65 to 10.47 — the remaining jumps have a different cause, which reproduces with `background: static` and therefore predates travelling backgrounds entirely. --- .../rustmotion-core/src/engine/transition.rs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 753b474..1cb9bfc 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -446,14 +446,23 @@ pub fn camera_pan_transition( // Translating an opaque image uncovers a strip on the opposite // side, and that strip reads as a hard edge just as much as a - // join would. Overscaling by the largest drift either background - // can take means neither ever exposes one. - let mx = (dx * BG_PARALLAX).abs(); - let my = (dy * BG_PARALLAX).abs(); + // join would. Each layer is therefore overscaled by exactly its + // own current displacement — just enough to cover, never more. + // + // Sizing it on the *maximum* drift instead makes the margin + // constant across the transition, including at both ends where the + // displacement is zero. The background then jumps between a normal + // frame and an enlarged one at every junction — measured at up to + // 128px of halo movement in a single frame, an order of magnitude + // beyond the drift itself. Tying the margin to the current offset + // makes it vanish exactly where a transition meets a normal frame, + // so the two are continuous. let w = width as f32; let h = height as f32; - let spread = - |ox: f32, oy: f32| Rect::from_ltrb(-mx + ox, -my + oy, w + mx + ox, h + my + oy); + let spread = |ox: f32, oy: f32| { + let (mx, my) = (ox.abs(), oy.abs()); + Rect::from_ltrb(-mx + ox, -my + oy, w + mx + ox, h + my + oy) + }; // The outgoing background stays opaque so the frame is always // covered; the incoming one dissolves over it. Fading both would