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
44 changes: 43 additions & 1 deletion .claude/skills/rustmotion/rules/dynamic-depth.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,55 @@

Static depth (see [depth-layering.md](depth-layering.md)) places elements at different perceived distances. Dynamic depth *animates* each plane independently so the scene breathes and the spatial composition is felt over time, not just read visually.

Three independent mechanisms combine freely:
Four independent mechanisms combine freely:

| Mechanism | Scope | Best for |
|---|---|---|
| **A. Per-element wiggle seeds** | One element | Uncorrelated floating per card/icon |
| **B. `float_3d` preset** | One element | Hero card with gentle 3D tilt loop |
| **C. Camera keyframes** | Whole scene | Cinematic zoom-in / slow pan |
| **D. `style.depth` (vraie parallaxe)** | Plans top-level | Parallaxe multi-plans pilotée par la caméra |

---

## Mechanism D — Vraie parallaxe caméra : `style.depth`

Quand la scène a une `camera`, chaque **enfant direct** de la scène est un plan. `style.depth` met à l'échelle l'effet caméra sur ce plan (pan × depth, zoom' = 1 + (zoom−1)×depth, rotation × depth, autour de `camera.origin`) :

| `depth` | Effet |
|---|---|
| `0` | Plan verrouillé — la caméra ne le bouge jamais (HUD, watermark, fond fixe) |
| `0.2–0.5` | Arrière-plan lointain — bouge peu (blobs, textures) |
| `1.0` | Plan normal (défaut — comportement identique sans depth) |
| `1.5–2.5` | Avant-plan amplifié — bouge plus que la caméra (profondeur dramatique) |

```json
{
"duration": 6.0,
"camera": {
"keyframes": [
{ "property": "x", "values": [ { "time": 0, "value": 0 }, { "time": 5, "value": 200 } ], "easing": "ease_in_out" }
]
},
"children": [
{ "type": "shape", "shape": "circle", "position": "absolute", "x": 100, "y": 200,
"fill": { "type": "radial", "colors": ["#6366F160", "#6366F100"] },
"style": { "width": 600, "height": 600, "depth": 0.3 } },
{ "type": "card", "style": { "depth": 1.0, "width": 800, "height": 400 } },
{ "type": "badge", "text": "LIVE", "position": "absolute", "x": 80, "y": 100,
"style": { "depth": 1.8 } }
]
}
```

Le fond (0.3) glisse lentement, la card suit la caméra, le badge file en avant-plan — parallaxe cinéma réelle, sans wiggle.

**Règles :**
- `depth` n'agit que sur les **enfants directs** de la scène (v1) ; il gouverne tout le sous-arbre du plan. Un `depth` sur un nœud imbriqué est sans effet caméra.
- Sans `camera` sur la scène, `depth` est inerte.
- `depth: 1.0` partout == aucun depth (byte-identique).
- Le point focal se règle avec `camera.origin` (`{ "x": px, "y": px }`, keyframable via `"origin.x"` / `"origin.y"`) — zoom vers un élément précis puis pan vers un autre.
- Combine avec le mécanisme A (wiggle) librement — la parallaxe caméra est déterministe, le wiggle ajoute la vie.

---

Expand Down
4 changes: 4 additions & 0 deletions crates/rustmotion-components/src/legacy_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ mod tests {
video_width: 200,
video_height: 200,
scene_duration: 1.0,
camera: None,
};
rustmotion_core::engine::paint_pass::paint_tree(
canvas,
Expand Down Expand Up @@ -313,6 +314,7 @@ mod tests {
video_width: 200,
video_height: 200,
scene_duration: 1.0,
camera: None,
};
rustmotion_core::engine::paint_pass::paint_tree(
canvas,
Expand Down Expand Up @@ -419,6 +421,7 @@ mod tests {
video_width: 200,
video_height: 200,
scene_duration: 1.0,
camera: None,
};
rustmotion_core::engine::paint_pass::paint_tree(
canvas,
Expand Down Expand Up @@ -486,6 +489,7 @@ mod tests {
video_width: 10,
video_height: 10,
scene_duration: 1.0,
camera: None,
};
// Wrong payload type — must not panic.
let bogus: Arc<dyn std::any::Any + Send + Sync> = Arc::new(42i64);
Expand Down
1 change: 1 addition & 0 deletions crates/rustmotion-components/tests/caption_presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ fn render_caption(json: serde_json::Value, time: f64) -> Vec<u8> {
video_width: W,
video_height: H,
scene_duration: 2.0,
camera: None,
};
paint_tree(canvas, &built.root, &layout, &frame, &dispatcher);

Expand Down
8 changes: 8 additions & 0 deletions crates/rustmotion-core/src/css/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ pub struct CssStyle {
pub perspective: Option<Length>,
pub perspective_origin: Option<TransformOrigin>,

// ---- Scene-camera parallax ----
/// Parallax plane depth for the scene camera (issue #90). 0 = locked
/// plane (the camera does not affect it), 1 = normal plane (default),
/// above 1 = amplified foreground. v1: effective on direct children of
/// the scene root only (each top-level child is one plane whose depth
/// governs its whole subtree). Not inherited via cascade.
pub depth: Option<f32>,

// ---- Overflow / stacking ----
pub overflow: Option<Overflow>,
pub overflow_x: Option<Overflow>,
Expand Down
68 changes: 64 additions & 4 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,51 @@ pub struct PaintFrame {
/// Total duration of the scene in seconds — used by the dispatcher to
/// compute animation progress (`time / scene_duration`).
pub scene_duration: f64,
/// Resolved scene camera for per-plane parallax (issue #90). `Some` only
/// when the scene declares a camera AND at least one top-level child has
/// an explicit `style.depth` — the paint pass then applies the camera per
/// plane (each direct child of the root, scaled by its depth) and the
/// caller must NOT apply the global camera transform.
pub camera: Option<PlaneCamera>,
}

/// Scene camera resolved at a fixed time, ready for per-plane application.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlaneCamera {
pub pan_x: f32,
pub pan_y: f32,
pub zoom: f32,
pub rotation: f32,
/// Focal point in frame pixels (already resolved; default = frame centre).
pub origin_x: f32,
pub origin_y: f32,
}

/// Apply the scene camera scaled by a plane `depth` (issue #90):
/// pan' = pan·d, zoom' = 1 + (zoom−1)·d, rotation' = rotation·d, around the
/// camera origin. `depth == 1` reproduces the global camera matrix exactly;
/// `depth == 0` is the identity (locked plane). The content-space viewport
/// clip mirrors the global path's clip-after-camera so plane content is cut
/// at the scene rectangle exactly like the single-transform path.
fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: (f32, f32)) {
let zoom = 1.0 + (cam.zoom - 1.0) * depth;
let rotation = cam.rotation * depth;
let pan_x = cam.pan_x * depth;
let pan_y = cam.pan_y * depth;

canvas.translate(Point::new(cam.origin_x, cam.origin_y));
if rotation.abs() > 0.001 {
canvas.rotate(rotation, None);
}
if (zoom - 1.0).abs() > 0.001 {
canvas.scale((zoom, zoom));
}
canvas.translate(Point::new(-cam.origin_x - pan_x, -cam.origin_y - pan_y));
canvas.clip_rect(
Rect::from_wh(viewport.0, viewport.1),
ClipOp::Intersect,
true,
);
}

/// Axis-aligned bounding box of a painted node, in device (video-pixel) coords.
Expand Down Expand Up @@ -121,7 +166,7 @@ pub fn paint_tree(
viewport_size: (frame.video_width as f32, frame.video_height as f32),
hits: None,
};
paint_node(canvas, root, &ctx);
paint_node(canvas, root, &ctx, 0);
}

/// Like [`paint_tree`] but also returns the per-frame hit-map: the on-screen
Expand All @@ -142,7 +187,7 @@ pub fn paint_tree_with_hits(
viewport_size: (frame.video_width as f32, frame.video_height as f32),
hits: Some(&hits),
};
paint_node(canvas, root, &ctx);
paint_node(canvas, root, &ctx, 0);
hits.into_inner()
}

Expand All @@ -154,7 +199,9 @@ struct PaintContext<'a> {
hits: Option<&'a RefCell<HitMap>>,
}

fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) {
/// `tree_depth` counts levels below the synthetic scene root (root = 0,
/// direct children = 1). Per-plane parallax cameras apply at level 1 only.
fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: usize) {
// Visibility window (start_at/end_at): the node keeps its layout space
// but paints nothing — subtree included — outside the window.
if let Some(window) = &node.window {
Expand All @@ -179,6 +226,16 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) {

canvas.save();

// 1.5 per-plane scene camera (issue #90): every direct child of the scene
// root is a plane; its explicit `depth` (default 1.0) scales the camera.
// Deeper nodes inherit their plane's transform via the canvas matrix.
if tree_depth == 1 {
if let Some(cam) = &ctx.frame.camera {
let depth = node.css.depth.unwrap_or(1.0);
apply_plane_camera(canvas, cam, depth, ctx.viewport_size);
}
}

// 2. transform
if node.css.transform.is_some() || node.css.perspective.is_some() {
let (tx, ty, _tz) =
Expand Down Expand Up @@ -333,7 +390,7 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) {
let mut indices: Vec<usize> = (0..node.children.len()).collect();
indices.sort_by_key(|&i| node.children[i].css.z_index.unwrap_or(0));
for &i in &indices {
paint_node(canvas, &node.children[i], ctx);
paint_node(canvas, &node.children[i], ctx, tree_depth + 1);
}

// inset shadows (after children so they overlay content)
Expand Down Expand Up @@ -1343,6 +1400,7 @@ mod hit_tests {
video_width: w,
video_height: h,
scene_duration: 1.0,
camera: None,
}
}

Expand Down Expand Up @@ -1586,6 +1644,7 @@ mod transform_origin_tests {
video_width: w,
video_height: h,
scene_duration: 1.0,
camera: None,
}
}

Expand Down Expand Up @@ -1977,6 +2036,7 @@ mod glassmorphism_tests {
video_width: w,
video_height: h,
scene_duration: 1.0,
camera: None,
}
}

Expand Down
20 changes: 18 additions & 2 deletions crates/rustmotion-core/src/schema/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,18 +334,34 @@ pub struct Camera {
/// Zoom factor. 1.0 = no zoom, 2.0 = 2x zoom in, 0.5 = zoom out.
#[serde(default = "default_camera_zoom")]
pub zoom: f32,
/// Rotation in degrees around the scene center. Default: 0.
/// Rotation in degrees around the camera origin. Default: 0.
#[serde(default)]
pub rotation: f32,
/// Focal point for zoom/rotation, in frame pixels. Absent = frame centre
/// (the historical behaviour). When the object is present, `x`/`y`
/// default to 0 (top-left corner) — set both explicitly.
#[serde(default)]
pub origin: Option<CameraOrigin>,
/// Keyframe animations for camera properties.
#[serde(default)]
pub keyframes: Vec<CameraKeyframe>,
}

/// Focal point of the camera in frame pixels.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct CameraOrigin {
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
}

/// A keyframe for a camera property.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CameraKeyframe {
/// The camera property to animate: "x", "y", "zoom", "rotation".
/// The camera property to animate: "x", "y", "zoom", "rotation",
/// "origin.x", "origin.y" (dotted form, matching the component keyframe
/// convention for compound properties).
pub property: String,
/// Time-value pairs for the animation.
pub values: Vec<CameraKeyframePoint>,
Expand Down
Loading
Loading