From 4b1390af20b6263717c8f9fff57a736b55b49cfa Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 01:00:55 +0200 Subject: [PATCH 1/2] fix(background): halo zones can be wide ellipses, not just circles (#344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reference video needs a thin, wide light band along the top edge, plus rotation — a single `radius` can only ever draw a circle. HaloZone gains optional radius_x/radius_y (falling back to radius when absent) and a rotation in degrees. Circularity is decided structurally, not just by whether the new fields are present: a zone is circular whenever effective_radius_x() equals effective_radius_y(), regardless of rotation (rotating a circle is a no-op, so it's not even attempted). That keeps every legacy radius-only scenario on the exact original draw_circle call — same operation order, same floating point values — rather than trusting that Skia's drawOval happens to match drawCircle bit-for-bit. Explicitly setting radius_x == radius_y == radius takes the same fast path for the same reason, which is what the new byte-identity test exercises directly. The blur mask sizes off the minor axis (min(radius_x, radius_y)), so a very flat ellipse doesn't drown in a blur scaled to its long axis. The transition-interpolation path (lerp_zones) now carries radius_x, radius_y and rotation across a halo-to-halo scene transition, so an ellipse shape change no longer snaps at the cut. Note: crates/rustmotion/skills/SKILL.md's rules index was intentionally left untouched (out of scope for this change) — rules/halo-shapes.md still needs a line added there to be discoverable from the skill. --- .../rustmotion-core/src/schema/background.rs | 130 ++++++++++++ crates/rustmotion/skills/rules/halo-shapes.md | 93 +++++++++ .../src/engine/render/background.rs | 196 +++++++++++++++++- 3 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 crates/rustmotion/skills/rules/halo-shapes.md diff --git a/crates/rustmotion-core/src/schema/background.rs b/crates/rustmotion-core/src/schema/background.rs index 36d06ff..bff844e 100644 --- a/crates/rustmotion-core/src/schema/background.rs +++ b/crates/rustmotion-core/src/schema/background.rs @@ -558,8 +558,29 @@ pub struct HaloZone { pub y: f32, /// Radius as a fraction of that surface's `max(width, height)` — so the /// same value covers proportionally the same area whichever view it is in. + /// This is also the fallback for `radius_x`/`radius_y` when either is + /// omitted, which is what keeps a zone written before those fields + /// existed a perfect, unrotated circle. #[serde(default = "default_halo_radius")] pub radius: f32, + /// Horizontal radius, same fraction-of-surface units as + /// [`HaloZone::radius`]. Omitted (the default) falls back to `radius`. + /// Set it together with `radius_y` to draw an ellipse instead of a + /// circle — a wide, thin light band wants `radius_x` far larger than + /// `radius_y`. + #[serde(default)] + pub radius_x: Option, + /// Vertical radius, same fraction-of-surface units as + /// [`HaloZone::radius`]. Omitted (the default) falls back to `radius` — + /// see [`HaloZone::radius_x`]. + #[serde(default)] + pub radius_y: Option, + /// Rotation of the ellipse in degrees, clockwise about its own center. + /// Ignored on a circular zone (`radius_x == radius_y`, which includes + /// every zone that only sets `radius`) — a rotated circle is a circle, + /// so it is never worth the extra draw call. + #[serde(default)] + pub rotation: 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 @@ -571,6 +592,20 @@ pub struct HaloZone { pub opacity: f32, } +impl HaloZone { + pub fn effective_radius_x(&self) -> f32 { + self.radius_x.unwrap_or(self.radius) + } + + pub fn effective_radius_y(&self) -> f32 { + self.radius_y.unwrap_or(self.radius) + } + + pub fn is_circular(&self) -> bool { + self.effective_radius_x() == self.effective_radius_y() + } +} + /// Transition configuration for background interpolation between scenes. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct BackgroundTransition { @@ -834,6 +869,9 @@ mod halo_zone_opacity_tests { x: 0.5, y: 0.5, radius: 0.4, + radius_x: None, + radius_y: None, + rotation: 0.0, opacity: 0.6, }; let v = serde_json::to_value(&zone).unwrap(); @@ -877,6 +915,98 @@ mod halo_zone_opacity_tests { } } +#[cfg(test)] +mod halo_zone_ellipse_tests { + use super::*; + + fn zone_with_radius_only(radius: f32) -> HaloZone { + serde_json::from_value(serde_json::json!({ "color": "#FFFFFF", "radius": radius })).unwrap() + } + + #[test] + fn radius_x_and_radius_y_default_to_none_when_omitted() { + let zone = zone_with_radius_only(0.4); + assert_eq!(zone.radius_x, None); + assert_eq!(zone.radius_y, None); + assert_eq!(zone.rotation, 0.0); + } + + #[test] + fn effective_radius_falls_back_to_radius_when_axis_radii_are_absent() { + let zone = zone_with_radius_only(0.4); + assert_eq!(zone.effective_radius_x(), 0.4); + assert_eq!(zone.effective_radius_y(), 0.4); + } + + #[test] + fn effective_radius_honours_explicit_axis_values() { + let zone: HaloZone = serde_json::from_value(serde_json::json!({ + "color": "#FFFFFF", + "radius": 0.4, + "radius_x": 0.8, + "radius_y": 0.05 + })) + .unwrap(); + assert_eq!(zone.effective_radius_x(), 0.8); + assert_eq!(zone.effective_radius_y(), 0.05); + } + + #[test] + fn a_radius_only_zone_is_circular() { + assert!(zone_with_radius_only(0.4).is_circular()); + } + + #[test] + fn explicit_equal_radius_x_and_radius_y_is_still_circular() { + let zone: HaloZone = serde_json::from_value(serde_json::json!({ + "color": "#FFFFFF", + "radius": 0.4, + "radius_x": 0.4, + "radius_y": 0.4 + })) + .unwrap(); + assert!(zone.is_circular()); + } + + #[test] + fn differing_axis_radii_are_not_circular() { + let zone: HaloZone = serde_json::from_value(serde_json::json!({ + "color": "#FFFFFF", + "radius": 0.4, + "radius_x": 0.8, + "radius_y": 0.1 + })) + .unwrap(); + assert!(!zone.is_circular()); + } + + #[test] + fn a_nonzero_rotation_does_not_affect_circularity() { + let zone: HaloZone = serde_json::from_value(serde_json::json!({ + "color": "#FFFFFF", + "radius": 0.4, + "rotation": 45.0 + })) + .unwrap(); + assert!( + zone.is_circular(), + "rotation has no visible effect on a circle, so it must not change how it renders" + ); + } + + #[test] + fn rotation_defaults_to_zero_degrees() { + let zone: HaloZone = serde_json::from_value(serde_json::json!({ + "color": "#FFFFFF", + "radius": 0.4, + "radius_x": 0.8, + "radius_y": 0.1 + })) + .unwrap(); + assert_eq!(zone.rotation, 0.0); + } +} + #[cfg(test)] mod animated_background_silent_sink_tests { use super::*; diff --git a/crates/rustmotion/skills/rules/halo-shapes.md b/crates/rustmotion/skills/rules/halo-shapes.md new file mode 100644 index 0000000..c39563b --- /dev/null +++ b/crates/rustmotion/skills/rules/halo-shapes.md @@ -0,0 +1,93 @@ +# `halo` — zones elliptiques (`radius_x`/`radius_y`/`rotation`) + +Jusqu'ici, une zone `halo` n'avait qu'un `radius` : un cercle, point. Trois +champs supplémentaires sur chaque zone permettent un ovale — utile pour un +filet de lumière fin et large en haut de cadre, une ambiance beaucoup plus +proche d'une vraie key light que le blob rond par défaut. + +| Champ | Type | Défaut | Rôle | +|---|---|---|---| +| `radius` | `f32` | `0.4` | Rayon du cercle, en fraction de `max(largeur, hauteur)` de la surface (viewport en vue `slide`, monde en vue `world`). Inchangé. | +| `radius_x` | `f32?` | absent → retombe sur `radius` | Rayon horizontal, mêmes unités que `radius`. | +| `radius_y` | `f32?` | absent → retombe sur `radius` | Rayon vertical, mêmes unités que `radius`. | +| `rotation` | `f32` | `0.0` | Rotation de l'ellipse en degrés, sens horaire, autour de son propre centre. | + +Tous en **snake_case** dans le JSON (`radius_x`, pas `radius-x`) — contrairement +au kebab-case de `animated-background` ou `world-position` au niveau scène. +`HaloZone` est un objet imbriqué (`zones: [...]`) et suit la casse de ses +voisins directs (`radius`, `opacity`), pas celle du schéma racine. + +## Compatibilité : un cercle reste un cercle, au bit près + +Omettre `radius_x`/`radius_y` retombe sur `radius` pour les deux axes — une +zone écrite avant l'existence de ces champs continue à produire exactement +les mêmes pixels. Ce n'est pas une promesse de "même rendu visuel" : le +moteur détecte qu'une zone est circulaire (`radius_x == radius_y` une fois +les valeurs par défaut appliquées) et prend alors le même chemin de code que +l'ancien `draw_circle`, sans jamais passer par l'ellipse ni par la rotation. +Fixer explicitement `radius_x`/`radius_y` à la même valeur que `radius` +produit donc le rendu identique à ne rien fixer du tout — c'est la même +branche qui s'exécute. + +**Corollaire :** `rotation` sur une zone circulaire est un pur no-op, pas +seulement "sans effet visuel" — le champ n'est même pas lu. Un cercle tourné +est un cercle ; ça n'aurait forcé qu'un calcul de matrice de rotation pour +rien, avec le risque de décaler l'anti-aliasing au bord d'un fragment de +pixel entre deux exécutions. `rotation` ne prend effet que si l'ellipse est +réellement ovale (`radius_x != radius_y`). + +## Recette : filet de lumière large et fin en haut de cadre + +```json +{ + "preset": "halo", + "zones": [ + { + "color": "#8B5CF6AA", + "x": 0.5, + "y": 0.02, + "radius_x": 0.85, + "radius_y": 0.07, + "rotation": 0 + } + ] +} +``` + +`x`/`y` restent le centre de l'ellipse (pas un coin) : `y: 0.02` place ce +centre presque au bord haut, et comme `radius_y` est petit, la moitié basse +de l'ellipse qui déborderait sous le cadre ne se voit simplement pas — pas +besoin de la sortir du viewport à la main. Une inclinaison légère se fait +avec `"rotation": -8` : le filet suit alors une diagonale au lieu d'être +parfaitement à plat. + +## Flou : calé sur l'axe le plus fin, pas sur le plus large + +Le flou gaussien de la zone est proportionnel au **plus petit** des deux +rayons effectifs (`min(radius_x, radius_y) * 0.15`), pas à leur moyenne ni au +plus grand. Un ovale large de `radius_x: 0.85` et fin de `radius_y: 0.07` +garde un bord net à l'échelle de son épaisseur réelle ; caler le flou sur +`radius_x` aurait noyé tout le filet dans un flou disproportionné par rapport +à sa hauteur. + +## Respiration (`breath`) : les deux axes bougent ensemble + +L'animation de respiration existante (le halo qui pulse doucement, pilotée +par `speed` sur le fond animé) multiplie `radius_x` et `radius_y` par le +**même** facteur à chaque frame — l'ellipse pulse en conservant son rapport +d'aspect, elle ne devient jamais plus ronde ou plus écrasée en respirant. + +## Ça marche aussi dans `view.background` (vue `world`) + +Rien de spécifique à `radius_x`/`radius_y`/`rotation` par rapport au reste de +`HaloZone` : la même zone posée en `view.background` d'une composition +`world` (voir [world-view.md](world-view.md) §4) hérite du même comportement, +`x`/`y`/`radius*` restant des fractions du monde plutôt que du viewport. + +## Transition entre deux fonds `halo` + +L'interpolation utilisée pour un `background.transition` entre deux scènes +`halo` lisse maintenant `radius_x`, `radius_y` et `rotation` au même titre que +la couleur ou la position — plus de saut brutal de forme à la coupe si la +scène d'arrivée a une ellipse différente (ou une rotation différente) de la +scène de départ. diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index 1e0b227..bddcba8 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -217,28 +217,51 @@ fn linear_to_srgb(c: f32) -> f32 { } fn draw_bg_halo(canvas: &Canvas, cfg: &HaloConfig, speed: f32, time: f32, width: f32, height: f32) { + let scale = width.max(height); for (i, zone) in cfg.zones.iter().enumerate() { let cx = zone.x * width; let cy = zone.y * height; - let base_radius = zone.radius * width.max(height); let phase = (zone.x * 17.3 + zone.y * 31.7 + i as f32 * 0.73).fract() * std::f32::consts::TAU; 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; let mut color = color4f_from_hex(&zone.color); color.a *= zone.opacity.clamp(0.0, 1.0); let mut paint = Paint::default(); paint.set_anti_alias(true); paint.set_color4f(color, None); + + let radius_x = zone.effective_radius_x() * scale * breath; + let radius_y = zone.effective_radius_y() * scale * breath; + + if zone.is_circular() { + paint.set_mask_filter(skia_safe::MaskFilter::blur( + skia_safe::BlurStyle::Normal, + radius_x * 0.15, + false, + )); + canvas.draw_circle((cx, cy), radius_x, &paint); + continue; + } + paint.set_mask_filter(skia_safe::MaskFilter::blur( skia_safe::BlurStyle::Normal, - radius * 0.15, + radius_x.min(radius_y) * 0.15, false, )); - canvas.draw_circle((cx, cy), radius, &paint); + + canvas.save(); + canvas.translate((cx, cy)); + if zone.rotation != 0.0 { + canvas.rotate(zone.rotation, None); + } + canvas.draw_oval( + skia_safe::Rect::from_xywh(-radius_x, -radius_y, radius_x * 2.0, radius_y * 2.0), + &paint, + ); + canvas.restore(); } } @@ -627,6 +650,9 @@ pub(super) fn interpolate_animated_bg( x: lerp(za.x, zb.x), y: lerp(za.y, zb.y), radius: lerp(za.radius, zb.radius), + radius_x: Some(lerp(za.effective_radius_x(), zb.effective_radius_x())), + radius_y: Some(lerp(za.effective_radius_y(), zb.effective_radius_y())), + rotation: lerp(za.rotation, zb.rotation), opacity: lerp(za.opacity, zb.opacity), }); } @@ -870,6 +896,168 @@ mod halo_opacity_tests { } } +#[cfg(test)] +mod halo_ellipse_shape_tests { + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + use crate::loader::load_scenario_from_source; + + 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") + } + + fn halo_scenario(zone_fields: &str) -> String { + format!( + r##"{{"video":{{"width":200,"height":200,"background":"#000000"}}, + "scenes":[{{"duration":1.0, + "background":{{"preset":"halo","speed":0, + "zones":[{{"color":"#FFFFFF","x":0.5,"y":0.5{zone_fields}}}]}} + ,"children":[]}}]}}"## + ) + } + + fn red_at(buf: &[u8], width: u32, x: u32, y: u32) -> u8 { + buf[((y * width + x) * 4) as usize] + } + + #[test] + fn radius_only_zone_renders_byte_identically_to_explicit_equal_radius_x_radius_y() { + let radius_only = render_first_frame(&halo_scenario(r#","radius":0.3"#)); + let explicit_axes = render_first_frame(&halo_scenario( + r#","radius":0.3,"radius_x":0.3,"radius_y":0.3"#, + )); + assert_eq!( + radius_only, explicit_axes, + "a radius-only zone must render exactly like radius_x == radius_y == radius" + ); + } + + #[test] + fn an_explicit_zero_rotation_on_a_radius_only_zone_is_a_true_noop() { + let without_field = render_first_frame(&halo_scenario(r#","radius":0.25"#)); + let with_field = render_first_frame(&halo_scenario(r#","radius":0.25,"rotation":0.0"#)); + assert_eq!( + without_field, with_field, + "rotation: 0.0 must be pixel-identical to omitting rotation" + ); + } + + #[test] + fn a_wide_ellipse_reaches_further_horizontally_than_vertically() { + let buf = render_first_frame(&halo_scenario( + r#","radius":0.3,"radius_x":0.45,"radius_y":0.05"#, + )); + assert!( + red_at(&buf, 200, 180, 100) > 200, + "a radius_x=0.45 ellipse should still be near full brightness 80px from center" + ); + assert!( + red_at(&buf, 200, 100, 180) < 10, + "a radius_y=0.05 ellipse should not reach 80px vertically from center" + ); + } + + #[test] + fn rotating_the_ellipse_changes_which_pixels_it_covers() { + let unrotated = render_first_frame(&halo_scenario( + r#","radius":0.3,"radius_x":0.4,"radius_y":0.08"#, + )); + let rotated = render_first_frame(&halo_scenario( + r#","radius":0.3,"radius_x":0.4,"radius_y":0.08,"rotation":90.0"#, + )); + assert_ne!( + unrotated, rotated, + "a 90-degree rotation on a non-circular zone must change the render" + ); + } + + #[test] + fn rotation_has_no_effect_on_a_circular_zone() { + let unrotated = render_first_frame(&halo_scenario(r#","radius":0.3"#)); + let rotated = render_first_frame(&halo_scenario(r#","radius":0.3,"rotation":37.0"#)); + assert_eq!( + unrotated, rotated, + "rotating a circle (radius_x == radius_y) must not change the render" + ); + } +} + +#[cfg(test)] +mod halo_zone_transition_interpolation_tests { + use super::*; + use crate::schema::HaloConfig; + + fn halo_bg(zones: Vec) -> AnimatedBackground { + AnimatedBackground { + preset: BackgroundPreset::Halo(HaloConfig { zones }), + x: 0.0, + y: 0.0, + speed: 0.0, + direction: None, + } + } + + fn zone(radius_x: f32, radius_y: f32, rotation: f32) -> HaloZone { + HaloZone { + color: "#FFFFFF".to_string(), + x: 0.5, + y: 0.5, + radius: radius_x, + radius_x: Some(radius_x), + radius_y: Some(radius_y), + rotation, + opacity: 1.0, + } + } + + #[test] + fn a_halo_transition_interpolates_ellipse_axes_and_rotation_instead_of_snapping() { + let a = halo_bg(vec![zone(0.2, 0.05, 0.0)]); + let b = halo_bg(vec![zone(0.6, 0.5, 90.0)]); + + let mid = interpolate_animated_bg(&a, &b, 0.5); + match mid.preset { + BackgroundPreset::Halo(cfg) => { + let z = &cfg.zones[0]; + assert_eq!(z.effective_radius_x(), 0.4); + assert_eq!(z.effective_radius_y(), 0.275); + assert_eq!(z.rotation, 45.0); + } + other => panic!("expected Halo, got {other:?}"), + } + } + + #[test] + fn interpolation_still_falls_back_through_radius_when_a_zone_only_set_it() { + let legacy_zone = HaloZone { + color: "#FFFFFF".to_string(), + x: 0.5, + y: 0.5, + radius: 0.2, + radius_x: None, + radius_y: None, + rotation: 0.0, + opacity: 1.0, + }; + let a = halo_bg(vec![legacy_zone]); + let b = halo_bg(vec![zone(0.6, 0.6, 0.0)]); + + let mid = interpolate_animated_bg(&a, &b, 0.5); + match mid.preset { + BackgroundPreset::Halo(cfg) => { + assert_eq!(cfg.zones[0].effective_radius_x(), 0.4); + assert_eq!(cfg.zones[0].effective_radius_y(), 0.4); + } + other => panic!("expected Halo, got {other:?}"), + } + } +} + #[cfg(test)] mod scroll_offset_wrap_tests { From 09fc11a491c9d2db5aac1e96c0701256af2a1fd0 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 01:12:48 +0200 Subject: [PATCH 2/2] docs(skills): link the halo shapes rule into the index --- crates/rustmotion/skills/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 6f71b6a..2928e83 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -233,6 +233,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/html-css-mental-model.md](rules/html-css-mental-model.md) - **CRITICAL:** Think HTML/CSS — flow layout first, absolute only for decorative/overlay elements - [rules/validate-json.md](rules/validate-json.md) - Always validate generated JSON with `rustmotion validate` before presenting +- [rules/halo-shapes.md](rules/halo-shapes.md) - `halo` beyond circles: `radius_x`/`radius_y`/`rotation` for a wide thin band of light, and why the blur follows the short axis - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet - [rules/even-dimensions.md](rules/even-dimensions.md) - Use even width/height for H.264 encoding