Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
285 changes: 272 additions & 13 deletions crates/rustmotion-components/src/box_builder.rs

Large diffs are not rendered by default.

24 changes: 15 additions & 9 deletions crates/rustmotion-components/src/pointer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color};
use rustmotion_core::schema::TimelineStep;
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};

use crate::cursor::{waypoint_offset, CursorPathEasing, CursorWaypoint};
use crate::cursor::{CursorPathEasing, CursorWaypoint};

/// Colour scheme of the pointer, so a scene picks one word instead of two
/// hex values that have to stay in contrast with each other.
Expand Down Expand Up @@ -308,16 +308,10 @@ impl Painter for Pointer {
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
let (dx, dy) = if self.path.is_empty() {
(0.0, 0.0)
} else {
waypoint_offset(&self.path, ctx.time, self.click_duration, self.path_easing)
};
let click = self.click_progress(ctx.time);
let (fill, outline) = self.colors();

canvas.save();
canvas.translate((dx, dy));

if let (Some(p), Some((stroke_f, travel_f))) = (click, self.click_ring.metrics()) {
let ring_fallback = self.ring_fallback_color(&fill, &outline);
Expand Down Expand Up @@ -373,6 +367,7 @@ impl Painter for Pointer {
#[cfg(test)]
mod tests {
use super::*;
use crate::cursor::waypoint_offset;

fn pointer(json: serde_json::Value) -> Pointer {
serde_json::from_value(json).expect("pointer fixture")
Expand Down Expand Up @@ -608,12 +603,23 @@ mod tests {
"size": 120.0,
"click_ring": "bold",
"click_duration": 0.5,
"path": [{ "time": 0.0, "x": 150.0, "y": 150.0 }]
"click_at": [0.0]
}));
const W: i32 = 300;
const H: i32 = 300;
let background = skia_safe::Color::from_argb(255, 20, 20, 20);
let mut surface = render(&p, W, H, 0.15, background);
let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface");
{
let canvas = surface.canvas();
canvas.clear(background);
canvas.translate((150.0, 150.0));
p.paint_content(
canvas,
&BoxLayout::default(),
&AnimatedProperties::default(),
&paint_ctx(0.15, W as u32, H as u32),
);
}

let radius = 0.3 * 1.25 * 120.0;
let offset = radius * std::f32::consts::FRAC_1_SQRT_2;
Expand Down
31 changes: 29 additions & 2 deletions crates/rustmotion-core/src/css/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1419,9 +1419,9 @@ pub enum FilterFn {
Blur {
#[serde(default)]
radius: Option<Length>,
#[serde(default, rename = "radius-x")]
#[serde(default, rename = "radius-x", alias = "radius_x")]
radius_x: Option<Length>,
#[serde(default, rename = "radius-y")]
#[serde(default, rename = "radius-y", alias = "radius_y")]
radius_y: Option<Length>,
},
/// A single-axis blur held at `angle` degrees (0 = along +x, 90 = along
Expand Down Expand Up @@ -2138,6 +2138,33 @@ mod tests {
));
}

#[test]
fn blur_filter_accepts_snake_case_radius_x_and_radius_y_as_aliases() {
let json = r#"{ "filter": [{ "fn": "blur", "radius_x": 40, "radius_y": 12 }] }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
let f = &s.filter.expect("filter set")[0];
match f {
FilterFn::Blur {
radius,
radius_x,
radius_y,
} => {
assert!(radius.is_none());
assert!(
matches!(radius_x, Some(Length::Px(v)) if (*v - 40.0).abs() < 1e-6),
"the snake_case spelling must be accepted exactly like radius-x, got {:?}",
radius_x
);
assert!(
matches!(radius_y, Some(Length::Px(v)) if (*v - 12.0).abs() < 1e-6),
"the snake_case spelling must be accepted exactly like radius-y, got {:?}",
radius_y
);
}
other => panic!("expected Blur, got {:?}", other),
}
}

#[test]
fn blur_filter_accepts_per_axis_radius_with_no_isotropic_radius() {
let json = r#"{ "filter": [{ "fn": "blur", "radius-x": 40, "radius-y": 0 }] }"#;
Expand Down
65 changes: 64 additions & 1 deletion crates/rustmotion-core/src/engine/transition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1289,8 +1289,8 @@ fn zoom_blur_transition(
surface_to_pixels(streak_surface, width, height)
}

const WHIP_STEPS: usize = 10;
const WHIP_MAX_REACH: f32 = 0.5;
const WHIP_STEPS: usize = 120;

fn whip_transition(
frame_a: &[u8],
Expand Down Expand Up @@ -1902,3 +1902,66 @@ mod iris_pill_shape_tests {
);
}
}

#[cfg(test)]
mod whip_continuity_tests {
use super::*;

fn solid_rgb(width: u32, height: u32, r: u8, g: u8, b: u8) -> Vec<u8> {
(0..width * height).flat_map(|_| [r, g, b, 255]).collect()
}

fn off_centre_stripe(width: u32, height: u32) -> Vec<u8> {
let mut out = Vec::with_capacity((width * height * 4) as usize);
let (x0, x1) = (width * 5 / 8, width * 7 / 8);
for _y in 0..height {
for x in 0..width {
if x >= x0 && x < x1 {
out.extend_from_slice(&[240, 240, 240, 255]);
} else {
out.extend_from_slice(&[10, 10, 10, 255]);
}
}
}
out
}

#[test]
fn a_wide_streak_has_no_large_jump_between_adjacent_pixels() {
const W: u32 = 640;
const H: u32 = 48;
let a = off_centre_stripe(W, H);
let b = solid_rgb(W, H, 40, 40, 40);
let out = whip_transition(&a, &b, W, H, 0.5, 6.0, TransitionDirection::Left);

let row = H / 2;
let value_at = |x: u32| -> i32 {
let i = ((row * W + x) * 4) as usize;
out[i] as i32
};

let shift = (0.5 * W as f32) as u32;
let sharp_edges = [W * 5 / 8 - shift, W * 7 / 8 - shift];
let excluded = |x: u32| sharp_edges.iter().any(|&e| x.abs_diff(e) < 20);

let mut max_jump = 0i32;
let mut jump_at = 0u32;
for x in 0..W - 1 {
if excluded(x) || excluded(x + 1) {
continue;
}
let jump = (value_at(x + 1) - value_at(x)).abs();
if jump > max_jump {
max_jump = jump;
jump_at = x;
}
}

assert!(
max_jump < 40,
"the streak must fade continuously across the trail, not in visible discrete \
bands stepping between a handful of stamped copies — the biggest single-pixel \
jump found was {max_jump} at x={jump_at} (row {row})"
);
}
}
32 changes: 32 additions & 0 deletions crates/rustmotion-core/src/schema/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,38 @@ pub struct Camera {
/// Keyframe animations for camera properties.
#[serde(default)]
pub keyframes: Vec<CameraKeyframe>,
/// Blurs the frame across a shutter window when the camera itself is
/// moving (pan, zoom, rotation or `shake`) — opt-in, since it renders
/// `samples` sub-frame exposures and averages them instead of one.
/// Absent (the default) renders exactly as before. Skipped on any frame
/// where the resolved camera pose does not change across the window, so
/// a static shot never pays the extra cost.
#[serde(default)]
pub motion_blur: Option<CameraMotionBlurConfig>,
}

/// Shutter-window camera motion blur, opt-in via `camera.motion_blur`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraMotionBlurConfig {
/// Sub-frame exposures averaged together. Each one is a full render, so
/// this is a direct cost multiplier — default `8`, clamped to `1..=16`.
#[serde(default = "default_camera_motion_blur_samples")]
pub samples: u32,
/// Exposure length as a multiple of one frame duration (`shutter / fps`
/// seconds), ending at the frame's own time. `0.5` (the default) covers
/// half a frame, the 180-degree shutter of a film camera; `1.0` leaves it
/// open for the whole frame and smears twice as far.
#[serde(default = "default_camera_motion_blur_shutter")]
pub shutter: f64,
}

fn default_camera_motion_blur_samples() -> u32 {
8
}

fn default_camera_motion_blur_shutter() -> f64 {
0.5
}

/// Where the light comes from, and how strong it is.
Expand Down
2 changes: 2 additions & 0 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con
- [rules/dot-map-orthographic.md](rules/dot-map-orthographic.md) - `dot_map` as a globe: orthographic projection, far-hemisphere culling, great-circle arcs and limb shading
- [rules/camera-3d.md](rules/camera-3d.md) - Tilt a whole shot with `camera.rotate_x`/`rotate_y`/`perspective`: one shared vanishing point, and rotation scaled by each plane's `style.depth`
- [rules/depth-of-field.md](rules/depth-of-field.md) - Defocus by plane: `camera.focus`/`aperture` on the `style.depth` scale, rack focus by keyframe, and why nothing moves without distinct depths
- [rules/camera-motion-blur.md](rules/camera-motion-blur.md) - Blur the shot itself on a fast pan/zoom: `camera.motion_blur: { samples, shutter }`, sub-frame accumulation, free on a still camera
- [rules/char-animation-rich-and-gradient-text.md](rules/char-animation-rich-and-gradient-text.md) - `char_*` presets on `rich_text` and `gradient_text`: stagger across span boundaries, per-span `ink_from`, and why `ink_from` is inert on a gradient
- [rules/text-morph.md](rules/text-morph.md) - `text.morph`: matched letters travel, unmatched ones fade or scramble — how it differs from `text.states` + `swap`
- [rules/text-component-parity.md](rules/text-component-parity.md) - Where `text`, `rich_text` and `gradient_text` disagreed: colour alpha, literal whitespace and baseline, the CSS angle convention and explicit `stops`
Expand Down Expand Up @@ -1638,6 +1639,7 @@ Scenes support a virtual camera with animatable pan, zoom, and rotation.
| `zoom` | f32 | `1.0` | Zoom factor (2.0 = 2x zoom in) |
| `rotation` | f32 | `0.0` | Rotation in degrees |
| `keyframes` | array | `[]` | `[{ "property", "values": [{ "time", "value" }], "easing" }]` |
| `motion_blur` | object | `null` | Sub-frame shutter accumulation when the camera itself moves — see [Virtual Camera motion blur](rules/camera-motion-blur.md) |

**Animatable properties:** `x`, `y`, `zoom`, `rotation`

Expand Down
55 changes: 26 additions & 29 deletions crates/rustmotion/skills/rules/camera-3d.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# Incliner tout le plan : `camera.rotate_x` / `rotate_y` / `perspective`
# Tilting the whole plane: `camera.rotate_x` / `rotate_y` / `perspective`

La parallaxe de `style.depth` connaît la translation et le zoom. Pour faire
basculer un plan entier, on copiait jusqu'ici les mêmes keyframes `rotate_x` sur
chaque groupe — et **chaque groupe se retrouvait avec son propre point de fuite**,
ce qui ne lit pas comme une caméra mais comme des cartes qui tournent chacune dans
son coin.
`style.depth` parallax knows about translation and zoom. To tilt a whole plane,
the workaround used to be copying the same `rotate_x` keyframes onto every group
— and **each group ended up with its own vanishing point**, which does not read
as a camera but as cards each spinning in its own corner.

```json
"camera": {
Expand All @@ -17,36 +16,34 @@ son coin.
}
```

| Champ | Défaut | Rôle |
| Field | Default | Role |
|---|---|---|
| `rotate_x` | `0` | bascule autour de l'axe horizontal, en degrés |
| `rotate_y` | `0` | bascule autour de l'axe vertical |
| `perspective` | `0` | distance d'observation en pixels ; `0` = projection orthographique |
| `rotate_x` | `0` | tilt about the horizontal axis, in degrees |
| `rotate_y` | `0` | tilt about the vertical axis |
| `perspective` | `0` | viewing distance in pixels; `0` = orthographic projection |

Les trois s'animent par `keyframes` comme `zoom` et `rotation`.
All three animate through `keyframes`, like `zoom` and `rotation`.

## Un seul point de fuite, et l'échelle par profondeur
## One vanishing point, and depth-scaled rotation

La perspective est appliquée **une fois**, autour de l'origine de la caméra. C'est
toute la différence avec la version copiée sur chaque groupe.
Perspective is applied **once**, around the camera's origin. That is the whole
difference from the version copied onto each group.

La rotation, elle, est mise à l'échelle par le `style.depth` de chaque enfant
direct de la scène — la même règle que la parallaxe et que `camera.focus` suivent
déjà. Un plan à `depth: 3` bascule trois fois plus qu'un plan à `depth: 1`, ce qui
est ce qui donne la sensation de volume.
The rotation, in turn, is scaled by each direct scene child's `style.depth` — the
same rule parallax and `camera.focus` already follow. A plane at `depth: 3` tilts
three times as much as one at `depth: 1`, and that is what gives the sense of
volume.

## Deux pièges
## Two traps

**Ça ne s'applique qu'aux enfants directs de la scène.** Comme la parallaxe : un
plan est une couche. Poser `depth` sur un nœud enfoui dans un sous-arbre ne le
fait pas basculer tout seul.
**It only applies to direct scene children.** Like parallax: a plane is a layer.
Putting `depth` on a node buried in a subtree does not tilt it on its own.

**`perspective: 0` n'est pas « pas de rotation ».** C'est une bascule
orthographique — la forme se déforme sans converger. Pour une caméra qui lit comme
une caméra, il faut une distance, et `1200`–`1800` couvre la plupart des cadrages.
**`perspective: 0` is not "no rotation".** It is an orthographic tilt — the shape
skews without converging. For a camera that reads as a camera you need a
distance, and `1200`–`1800` covers most framings.

## Ce qui n'y est pas
## See also

Le flou de mouvement de caméra (#362 partie 2) n'est pas ici. Il demande
d'accumuler plusieurs rendus sous-frame le long du mouvement, ce qui vit dans la
boucle d'encodage et non dans le pass de peinture.
Camera motion blur (#362) is now implemented — `camera.motion_blur`, see
[rules/camera-motion-blur.md](camera-motion-blur.md).
55 changes: 55 additions & 0 deletions crates/rustmotion/skills/rules/camera-motion-blur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Rule: `camera.motion_blur` — blurring the shot, not the components

`motion_blur` in `style.animation` trails **one component** along its own animation (see [rules/motion-blur-and-trail.md](motion-blur-and-trail.md)). It sees nothing when the thing moving is the camera: on a fast pan the whole frame should smear, and every component is standing still in its own coordinates.

`camera.motion_blur` is the other half. It opens a shutter window ending at the frame's own time, renders `samples` sub-frame exposures inside it, and averages them — the same thing a real camera does with a real shutter.

```json
"camera": {
"motion_blur": { "samples": 8, "shutter": 1.0 },
"keyframes": [
{ "property": "x", "easing": "ease_in_out",
"values": [{ "time": 0, "value": 0 }, { "time": 2, "value": -900 }] }
]
}
```

| Field | Type | Default | Role |
|---|---|---|---|
| `samples` | int | `8` | Sub-frame exposures averaged together, clamped to `1..=16`. Each one is a full render. |
| `shutter` | f64 | `0.5` | Exposure length as a multiple of one frame (`shutter / fps` seconds), ending at the frame's own time. `0.5` is the 180-degree shutter of a film camera; `1.0` leaves it open for the whole frame and smears twice as far. |

Absent (the default), nothing changes: the frame renders exactly as it did before.

## Cost is a direct multiplier, and you only pay it while the camera moves

`samples` full renders per frame is not an implementation detail to be optimised away later — it is what the feature *is*. Measured at 1920×1080 on a scene with a grid background, three shapes and two text blocks, taking the slope over 180 frames so process startup drops out:

| | per frame |
|---|---|
| no `camera.motion_blur` | **4.1 ms** |
| `samples: 8`, `shutter: 1.0`, camera panning and zooming | **8.8 ms** |

2.2× end to end, not 8×, because encoding and frame setup are paid once per frame whatever the render costs. The *render* portion does scale by `samples`, so the ratio climbs toward `samples` as the scene itself gets heavier. Budget for it before putting this on a long shot.

Every frame resolves the camera pose (`x`, `y`, `zoom`, `rotation`, plus `scene.shake`) at both ends of the shutter window. If they match, the frame takes the ordinary single-render path and costs nothing extra — a held shot in the middle of a scenario that blurs its pans is free.

## It is a shutter, so content blurs too

The sub-frames resample *everything* at their own instant, not just the camera transform. A component animating fast during a camera pan smears as well. That is what a physical exposure does, and pinning content to the nominal frame while only the camera moves would mean threading a second, independent time channel through the render core.

Practical consequence: do not combine a fast `camera.motion_blur` with a component that is itself mid-`char_*` stagger unless you want both softened.

## An animated background does not smear

`scene["animated-background"]` is painted in screen space and does not pan with the camera — verified: its grid columns sit at the same x at `time: 0` and at `time: 1.0` of a 900px pan. Since it does not move, averaging the sub-frames leaves it crisp. If you want the backdrop to carry the pan, it has to be real content in the scene tree, not a background preset.

## Not to be confused with

- **`style.animation: [{ "name": "motion_blur" }]`** — per component, ghost copies or a `smear` filter. Does nothing for a camera move.
- **the `whip` / `zoom_blur` transitions** — those composite two already-rendered frame buffers between scenes; they never re-render anything. See [rules/whip-transition.md](whip-transition.md).
- **`camera.focus`/`aperture`** — defocus by depth plane, a static property of the shot rather than a function of its movement. See [rules/depth-of-field.md](depth-of-field.md).

## Where it lives

In `render_frame_v2_scaled` (`crates/rustmotion/src/engine/render/scene.rs`), not in the encode loop: `render/mod.rs` re-exports scene's public items through an explicit named list, and the frame path already funnels through that one function. The encode side calls exactly what it called before and gets an already-averaged buffer back, so `apply_post_effects` still runs once on the final composited frame rather than once per sub-sample.
Loading
Loading