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 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-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/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/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-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-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 541bcf3..1cb9bfc 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,83 @@ 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 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) => { + // 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. 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| { + 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 + // 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. + 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/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-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-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 { 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, diff --git a/crates/rustmotion/src/engine/render/background.rs b/crates/rustmotion/src/engine/render/background.rs index 2258198..c507947 100644 --- a/crates/rustmotion/src/engine/render/background.rs +++ b/crates/rustmotion/src/engine/render/background.rs @@ -204,14 +204,24 @@ 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; - 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 +446,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 +539,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" + ); + } +} 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); diff --git a/examples/dark-premium.json b/examples/dark-premium.json new file mode 100644 index 0000000..f9685da --- /dev/null +++ b/examples/dark-premium.json @@ -0,0 +1,1180 @@ +{ + "version": "1.0", + "video": { + "width": 1920, + "height": 1080, + "fps": 30, + "background": "#06080F" + }, + "scenes": [ + { + "duration": 3.2, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E3A8A", + "x": 0.14, + "y": 0.16, + "radius": 0.26, + "opacity": 0.3 + }, + { + "color": "#2A4FBF", + "x": 0.78, + "y": 0.72, + "radius": 0.18, + "opacity": 0.1 + } + ] + }, + "world-position": { + "x": 0, + "y": 0 + }, + "children": [ + { + "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": "#3A1E6E", + "x": 0.22, + "y": 0.2, + "radius": 0.22, + "opacity": 0.26 + }, + { + "color": "#1E3A8A", + "x": 0.72, + "y": 0.68, + "radius": 0.2, + "opacity": 0.18 + }, + { + "color": "#5B2A8A", + "x": 0.52, + "y": 0.42, + "radius": 0.16, + "opacity": 0.12 + } + ] + }, + "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", + "background": "travel" + } + }, + { + "duration": 2.8, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E4FBF", + "x": 0.18, + "y": 0.22, + "radius": 0.3, + "opacity": 0.38 + }, + { + "color": "#5A8CFF", + "x": 0.8, + "y": 0.7, + "radius": 0.22, + "opacity": 0.16 + } + ] + }, + "world-position": { + "x": 1920, + "y": 1080 + }, + "children": [ + { + "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", + "background": "travel" + } + }, + { + "duration": 3.2, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#12326E", + "x": 0.12, + "y": 0.18, + "radius": 0.28, + "opacity": 0.28 + }, + { + "color": "#2F6BCF", + "x": 0.84, + "y": 0.76, + "radius": 0.2, + "opacity": 0.14 + } + ] + }, + "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", + "background": "travel" + } + }, + { + "duration": 2.8, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#7A1A55", + "x": 0.8, + "y": 0.72, + "radius": 0.28, + "opacity": 0.34 + }, + { + "color": "#1E3A8A", + "x": 0.16, + "y": 0.24, + "radius": 0.22, + "opacity": 0.2 + } + ] + }, + "world-position": { + "x": 5760, + "y": 1080 + }, + "children": [ + { + "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", + "background": "travel" + } + }, + { + "duration": 2.8, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#16386E", + "x": 0.2, + "y": 0.24, + "radius": 0.26, + "opacity": 0.26 + }, + { + "color": "#8A2A66", + "x": 0.82, + "y": 0.74, + "radius": 0.2, + "opacity": 0.18 + } + ] + }, + "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", + "background": "travel" + } + }, + { + "duration": 3.0, + "background": { + "preset": "halo", + "zones": [ + { + "color": "#1E3A8A", + "x": 0.14, + "y": 0.18, + "radius": 0.28, + "opacity": 0.34 + }, + { + "color": "#E8409A", + "x": 0.84, + "y": 0.8, + "radius": 0.24, + "opacity": 0.24 + } + ] + }, + "world-position": { + "x": 7680, + "y": 0 + }, + "children": [ + { + "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", + "background": "travel" + } + } + ] +}