From 6ebadd784a43fe3316dbca4a26df9a3bd9566468 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 20 Jul 2026 00:49:20 +0200 Subject: [PATCH] feat(engine): camera focal point and multi-plane parallax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Camera gains origin {x, y} (frame px, absent = centre, keyframable as origin.x/origin.y following the component dotted convention): translate/rotate/zoom pivot around the resolved origin at all four call sites — zoom onto an element, pan the focal point between keyframes - CssStyle gains depth (default 1.0): planes are the scene's direct children; with a camera and any explicit depth, rendering switches to per-plane camera application (pan*d, zoom'=1+(zoom-1)*d, rotation*d around the origin, content-space clip) carried through PaintFrame; depth 0 locks a background, >1 amplifies foregrounds; no depth declared anywhere keeps the global path untouched and the depth-1 invariant is byte-identity-tested against it - hit-map follows each plane's matrix (tested), geometry validate is layout-space (unaffected), world views stay on the global path, incremental hashing follows automatically; dynamic-depth skill rule gains the real-parallax mechanism --- .../skills/rustmotion/rules/dynamic-depth.md | 44 ++- .../src/legacy_dispatch.rs | 4 + .../tests/caption_presets.rs | 1 + crates/rustmotion-core/src/css/style.rs | 8 + .../rustmotion-core/src/engine/paint_pass.rs | 68 +++- crates/rustmotion-core/src/schema/scenario.rs | 20 +- crates/rustmotion/src/engine/render/scene.rs | 161 ++++++-- crates/rustmotion/src/tests.rs | 364 ++++++++++++++++++ 8 files changed, 635 insertions(+), 35 deletions(-) diff --git a/.claude/skills/rustmotion/rules/dynamic-depth.md b/.claude/skills/rustmotion/rules/dynamic-depth.md index 08a1f26..eeca037 100644 --- a/.claude/skills/rustmotion/rules/dynamic-depth.md +++ b/.claude/skills/rustmotion/rules/dynamic-depth.md @@ -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. --- diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 4bf360f..184a7c7 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -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, @@ -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, @@ -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, @@ -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 = Arc::new(42i64); diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs index 7a575a5..d055f31 100644 --- a/crates/rustmotion-components/tests/caption_presets.rs +++ b/crates/rustmotion-components/tests/caption_presets.rs @@ -56,6 +56,7 @@ fn render_caption(json: serde_json::Value, time: f64) -> Vec { video_width: W, video_height: H, scene_duration: 2.0, + camera: None, }; paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index c10b4b6..3570daf 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -104,6 +104,14 @@ pub struct CssStyle { pub perspective: Option, pub perspective_origin: Option, + // ---- 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, + // ---- Overflow / stacking ---- pub overflow: Option, pub overflow_x: Option, diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index ede7dcd..c306ee8 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -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, +} + +/// 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. @@ -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 @@ -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() } @@ -154,7 +199,9 @@ struct PaintContext<'a> { hits: Option<&'a RefCell>, } -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 { @@ -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) = @@ -333,7 +390,7 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) { let mut indices: Vec = (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) @@ -1343,6 +1400,7 @@ mod hit_tests { video_width: w, video_height: h, scene_duration: 1.0, + camera: None, } } @@ -1586,6 +1644,7 @@ mod transform_origin_tests { video_width: w, video_height: h, scene_duration: 1.0, + camera: None, } } @@ -1977,6 +2036,7 @@ mod glassmorphism_tests { video_width: w, video_height: h, scene_duration: 1.0, + camera: None, } } diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index dc5df6b..01ae27c 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -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, /// Keyframe animations for camera properties. #[serde(default)] pub keyframes: Vec, } +/// 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, diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 175a2c6..dcef5b7 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -12,6 +12,7 @@ use rustmotion_core::css::style::{ }; use rustmotion_core::css::units::LengthPercentage; use rustmotion_core::engine::animator::safe_div; +use rustmotion_core::engine::paint_pass::PlaneCamera; use rustmotion_core::engine::renderer::color4f_from_hex; /// Internal render-time context — bundles per-scene timing/dimension info that @@ -27,6 +28,48 @@ struct RenderContext { video_height: u32, #[allow(dead_code)] stagger_offset: f64, + /// Resolved scene camera for per-plane parallax (issue #90). `Some` only + /// when the scene has a camera AND at least one top-level child declares + /// `style.depth` — the paint pass then applies the camera per plane and + /// the global `apply_camera_transform` must be skipped. + camera: Option, +} + +/// True when any direct child of the scene declares an explicit `style.depth` +/// — the v1 parallax plane rule (planes = top-level children only). +fn scene_uses_depth(children: &[ChildComponent]) -> bool { + children + .iter() + .any(|c| c.component.as_styled().style_config().depth.is_some()) +} + +/// Resolve the scene camera at `time` into the flat state consumed by the +/// per-plane paint path. +fn resolve_plane_camera(camera: &Camera, time: f32, vw: f32, vh: f32) -> PlaneCamera { + let (origin_x, origin_y) = resolve_camera_origin(camera, time, vw, vh); + PlaneCamera { + pan_x: interpolate_camera_property(camera, "x", time), + pan_y: interpolate_camera_property(camera, "y", time), + zoom: interpolate_camera_property(camera, "zoom", time), + rotation: interpolate_camera_property(camera, "rotation", time), + origin_x, + origin_y, + } +} + +/// Decide the camera mode for a slide-scene render: per-plane (`Some`) when +/// depth is in play, otherwise `None` (caller applies the global transform). +fn per_plane_camera( + scene: &Scene, + children: &[ChildComponent], + time: f32, + vw: f32, + vh: f32, +) -> Option { + match &scene.camera { + Some(cam) if scene_uses_depth(children) => Some(resolve_plane_camera(cam, time, vw, vh)), + _ => None, + } } /// Render a complete frame using the v2 component pipeline. @@ -202,6 +245,15 @@ pub fn render_frame_v2_scaled( } } + // Camera mode: per-plane when depth is declared, global otherwise. + let plane_cam = per_plane_camera( + scene, + root_children, + time as f32, + config.width as f32, + config.height as f32, + ); + // Build render context let ctx = RenderContext { time, @@ -211,21 +263,24 @@ pub fn render_frame_v2_scaled( video_width: config.width, video_height: config.height, stagger_offset: 0.0, + camera: plane_cam, }; - // Apply virtual camera transform - let camera_guard = if let Some(ref camera) = scene.camera { - let g = super::CanvasGuard::new(canvas); - apply_camera_transform( - canvas, - camera, - time as f32, - config.width as f32, - config.height as f32, - ); - Some(g) - } else { - None + // Apply the global virtual camera transform (skipped in per-plane mode — + // the paint pass applies it per top-level plane, scaled by depth). + let camera_guard = match &scene.camera { + Some(camera) if plane_cam.is_none() => { + let g = super::CanvasGuard::new(canvas); + apply_camera_transform( + canvas, + camera, + time as f32, + config.width as f32, + config.height as f32, + ); + Some(g) + } + _ => None, }; // Clip scene content to viewport dimensions so scaled elements don't overflow. @@ -377,6 +432,7 @@ fn render_with_new_pipeline_iter<'a, I>( video_width: ctx.video_width, video_height: ctx.video_height, scene_duration: ctx.scene_duration, + camera: ctx.camera, }; paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); } @@ -567,13 +623,18 @@ pub fn render_scene_hits( }; let canvas = surface.canvas(); - // Match the camera transform so hit rects line up with the rendered frame. - let _camera_guard = if let Some(ref camera) = scene.camera { - let g = super::CanvasGuard::new(canvas); - apply_camera_transform(canvas, camera, time as f32, vw, vh); - Some(g) - } else { - None + // Match the camera transform so hit rects line up with the rendered + // frame. In per-plane mode the paint pass applies the (depth-scaled) + // camera per top-level plane; the canvas matrix at each node then feeds + // `local_to_device` so hit rects follow their plane automatically. + let plane_cam = per_plane_camera(scene, &children, time as f32, vw, vh); + let _camera_guard = match &scene.camera { + Some(camera) if plane_cam.is_none() => { + let g = super::CanvasGuard::new(canvas); + apply_camera_transform(canvas, camera, time as f32, vw, vh); + Some(g) + } + _ => None, }; let root_css = root_style(scene.layout.as_ref()); @@ -592,6 +653,7 @@ pub fn render_scene_hits( video_width: config.width, video_height: config.height, scene_duration: scene.duration, + camera: plane_cam, }; let hits = paint_tree_with_hits(canvas, &built.root, &layout, &frame, &dispatcher); @@ -794,6 +856,8 @@ pub fn render_world_frame_scaled( // Use local_time for animations (clamped to 0 if pan hasn't finished) let anim_time = vis.local_time.max(0.0); + // World views keep the global per-scene camera (depth planes are a + // slide-view feature; the world pan is a separate transform). let ctx = RenderContext { time: anim_time, scene_duration: scene.duration, @@ -802,6 +866,7 @@ pub fn render_world_frame_scaled( video_width: config.width, video_height: config.height, stagger_offset: 0.0, + camera: None, }; // Apply per-scene camera if present @@ -962,6 +1027,13 @@ pub fn render_scene_fg_scaled( // Transparent background canvas.clear(skia_safe::Color4f::new(0.0, 0.0, 0.0, 0.0)); + let plane_cam = per_plane_camera( + scene, + &children, + time as f32, + config.width as f32, + config.height as f32, + ); let ctx = RenderContext { time, scene_duration: scene.duration, @@ -970,10 +1042,11 @@ pub fn render_scene_fg_scaled( video_width: config.width, video_height: config.height, stagger_offset: 0.0, + camera: plane_cam, }; - let has_camera = scene.camera.is_some(); - if let Some(ref camera) = scene.camera { + let has_camera = scene.camera.is_some() && plane_cam.is_none(); + if let (Some(camera), None) = (&scene.camera, plane_cam) { apply_camera_transform( canvas, camera, @@ -1034,6 +1107,8 @@ pub(super) fn interpolate_camera_property(camera: &Camera, property: &str, time: "y" => camera.y, "zoom" => camera.zoom, "rotation" => camera.rotation, + "origin.x" => camera.origin.as_ref().map(|o| o.x).unwrap_or(0.0), + "origin.y" => camera.origin.as_ref().map(|o| o.y).unwrap_or(0.0), _ => 0.0, }; } @@ -1070,7 +1145,39 @@ pub(super) fn interpolate_camera_property(camera: &Camera, property: &str, time: points[points.len() - 1].value } -/// Apply camera transform to the canvas: translate, zoom, rotate around scene center. +/// Resolve the camera focal point at `time`, in frame pixels (issue #89). +/// +/// Priority per axis: keyframe track (`"origin.x"` / `"origin.y"`, dotted +/// like component compound properties) > static `camera.origin` > frame +/// centre (the historical hard-coded pivot — byte-identical when no origin +/// is declared). +pub(super) fn resolve_camera_origin( + camera: &Camera, + time: f32, + width: f32, + height: f32, +) -> (f32, f32) { + let has_track = |p: &str| { + camera + .keyframes + .iter() + .any(|k| k.property == p && !k.values.is_empty()) + }; + let ox = if camera.origin.is_some() || has_track("origin.x") { + interpolate_camera_property(camera, "origin.x", time) + } else { + width / 2.0 + }; + let oy = if camera.origin.is_some() || has_track("origin.y") { + interpolate_camera_property(camera, "origin.y", time) + } else { + height / 2.0 + }; + (ox, oy) +} + +/// Apply camera transform to the canvas: translate, zoom, rotate around the +/// camera origin (default: scene centre). pub(super) fn apply_camera_transform( canvas: &Canvas, camera: &Camera, @@ -1082,13 +1189,11 @@ pub(super) fn apply_camera_transform( let y = interpolate_camera_property(camera, "y", time); let zoom = interpolate_camera_property(camera, "zoom", time); let rotation = interpolate_camera_property(camera, "rotation", time); - - let cx = width / 2.0; - let cy = height / 2.0; + let (cx, cy) = resolve_camera_origin(camera, time, width, height); canvas.save(); - // 1. Translate to center + // 1. Translate to the focal point canvas.translate((cx, cy)); // 2. Apply rotation if rotation.abs() > 0.001 { @@ -1098,6 +1203,6 @@ pub(super) fn apply_camera_transform( if (zoom - 1.0).abs() > 0.001 { canvas.scale((zoom, zoom)); } - // 4. Translate back from center + apply camera pan offset + // 4. Translate back from the focal point + apply camera pan offset canvas.translate((-cx - x, -cy - y)); } diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 0cb3163..07e0368 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -303,6 +303,7 @@ mod component_smoke { video_width: 400, video_height: 300, scene_duration: 1.0, + camera: None, }; let mut failures = Vec::new(); @@ -365,6 +366,7 @@ mod component_smoke { video_width: 400, video_height: 300, scene_duration: 1.0, + camera: None, }; let mut failures = Vec::new(); @@ -471,6 +473,7 @@ mod component_smoke { video_width: w, video_height: h, scene_duration, + camera: None, }; paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); @@ -1494,6 +1497,7 @@ mod svg_draw_on_tests { video_width: w, video_height: h, scene_duration, + camera: None, }; paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); @@ -1811,6 +1815,7 @@ mod audio_tests { video_width: w as u32, video_height: h as u32, scene_duration: 10.0, + camera: None, }, &dispatcher, ); @@ -2117,6 +2122,7 @@ mod motion_blur_trail { video_width: w, video_height: h, scene_duration, + camera: None, }; paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); let row_bytes = w as usize * 4; @@ -2489,3 +2495,361 @@ mod post_effects_pipeline { ); } } + +#[cfg(test)] +mod camera_focal_tests { + //! Issue #89: camera focal point (`camera.origin`) — the zoom/rotation + //! pivot becomes configurable and keyframable instead of the hard-coded + //! frame centre. + + use crate::engine::render::render_frame_v2; + use crate::schema::{Scene, VideoConfig}; + + fn config(w: u32, h: u32) -> VideoConfig { + serde_json::from_value(serde_json::json!({ "width": w, "height": h, "fps": 30 })) + .expect("config") + } + + /// Render one frame of a scene described as JSON; returns RGBA bytes. + pub(super) fn render_scene_json( + scene_json: serde_json::Value, + w: u32, + h: u32, + frame: u32, + ) -> Vec { + let scene: Scene = serde_json::from_value(scene_json).expect("scene json"); + let children = crate::engine::render::deserialize_children(&scene); + render_frame_v2(&config(w, h), &scene, frame, 120, &children).expect("render") + } + + /// Centroid (x, y) of pixels dominated by the given channel (0=r, 2=b). + pub(super) fn channel_centroid(buf: &[u8], w: u32, h: u32, channel: usize) -> (f32, f32) { + let (mut sx, mut sy, mut n) = (0.0f64, 0.0f64, 0.0f64); + for y in 0..h { + for x in 0..w { + let i = ((y * w + x) * 4) as usize; + let v = buf[i + channel]; + let others: u16 = (0..3) + .filter(|c| *c != channel) + .map(|c| buf[i + c] as u16) + .sum(); + if v > 180 && others < 160 { + sx += x as f64; + sy += y as f64; + n += 1.0; + } + } + } + if n == 0.0 { + (-1.0, -1.0) + } else { + ((sx / n) as f32, (sy / n) as f32) + } + } + + fn red_rect_scene_with_camera(camera: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "duration": 4.0, + "camera": camera, + "children": [{ + "type": "shape", + "shape": "rect", + "fill": "#ff0000", + "position": "absolute", + "x": 60, "y": 40, + "style": { "width": "100px", "height": "80px" } + }] + }) + } + + #[test] + fn zoom_origin_top_left_differs_predictably_from_center() { + // Rect at (60..160, 40..120) in a 400x300 frame, zoom 2x. + // Origin (0,0): every point p maps to 2p → rect at (120..320, 80..240), + // centroid ~(220, 160). + // Center origin (200,150): p → 2p - (200,150) → rect at + // (-80..120, -70..90), visible part (0..120, 0..90), centroid ~(60, 45). + let buf_center = render_scene_json( + red_rect_scene_with_camera(serde_json::json!({ "zoom": 2.0 })), + 400, + 300, + 0, + ); + let buf_tl = render_scene_json( + red_rect_scene_with_camera( + serde_json::json!({ "zoom": 2.0, "origin": { "x": 0, "y": 0 } }), + ), + 400, + 300, + 0, + ); + + let (cx_c, cy_c) = channel_centroid(&buf_center, 400, 300, 0); + let (cx_tl, cy_tl) = channel_centroid(&buf_tl, 400, 300, 0); + + assert!( + (cx_tl - 219.5).abs() < 4.0 && (cy_tl - 159.5).abs() < 4.0, + "top-left origin zoom: expected centroid ~(220,160), got ({cx_tl},{cy_tl})" + ); + assert!( + (cx_c - 59.5).abs() < 4.0 && (cy_c - 44.5).abs() < 4.0, + "center origin zoom: expected centroid ~(60,45), got ({cx_c},{cy_c})" + ); + } + + #[test] + fn origin_at_center_is_byte_identical_to_absent() { + let buf_absent = render_scene_json( + red_rect_scene_with_camera(serde_json::json!({ "zoom": 2.0, "rotation": 17.0 })), + 400, + 300, + 0, + ); + let buf_center = render_scene_json( + red_rect_scene_with_camera(serde_json::json!({ + "zoom": 2.0, "rotation": 17.0, "origin": { "x": 200, "y": 150 } + })), + 400, + 300, + 0, + ); + assert_eq!( + buf_absent, buf_center, + "origin at frame centre must be byte-identical to absent origin" + ); + } + + #[test] + fn keyframed_origin_moves_visible_content_at_fixed_zoom() { + // Zoom fixed at 2; origin animates (0,0) → frame centre (200,150) + // over 2s. The pivot change alone must move the rendered content + // between frames while keeping the rect visible in both. + let scene = red_rect_scene_with_camera(serde_json::json!({ + "zoom": 2.0, + "keyframes": [ + { "property": "origin.x", "values": [ { "time": 0.0, "value": 0.0 }, { "time": 2.0, "value": 200.0 } ] }, + { "property": "origin.y", "values": [ { "time": 0.0, "value": 0.0 }, { "time": 2.0, "value": 150.0 } ] } + ] + })); + let buf_t0 = render_scene_json(scene.clone(), 400, 300, 0); + let buf_t2 = render_scene_json(scene, 400, 300, 60); // 60 / 30fps = 2s + + let (x0, y0) = channel_centroid(&buf_t0, 400, 300, 0); + let (x2, y2) = channel_centroid(&buf_t2, 400, 300, 0); + assert!(x0 >= 0.0 && x2 >= 0.0, "red must be visible in both frames"); + let dist = ((x2 - x0).powi(2) + (y2 - y0).powi(2)).sqrt(); + assert!( + dist > 50.0, + "keyframed origin must move content: t0=({x0},{y0}) t2=({x2},{y2}) dist={dist}" + ); + } +} + +#[cfg(test)] +mod parallax_tests { + //! Issue #90: multi-plane parallax — `style.depth` scales the scene + //! camera per top-level plane (0 = locked, 1 = normal, >1 = amplified). + + use super::camera_focal_tests::{channel_centroid, render_scene_json}; + + fn rect(color: &str, x: f32, y: f32, depth: Option) -> serde_json::Value { + let mut style = serde_json::json!({ "width": "80px", "height": "60px" }); + if let Some(d) = depth { + style["depth"] = serde_json::json!(d); + } + serde_json::json!({ + "type": "shape", "shape": "rect", "fill": color, + "position": "absolute", "x": x, "y": y, + "style": style + }) + } + + fn scene(camera: serde_json::Value, children: Vec) -> serde_json::Value { + serde_json::json!({ "duration": 4.0, "camera": camera, "children": children }) + } + + #[test] + fn depth_zero_locks_plane_while_depth_one_pans() { + // Camera pans x 0 → 100 over 2s. Blue rect depth 0 must not move; + // red rect (depth 1, activated by the blue's explicit depth) moves + // left by 100. + let cam = serde_json::json!({ + "keyframes": [ + { "property": "x", "values": [ { "time": 0.0, "value": 0.0 }, { "time": 2.0, "value": 100.0 } ] } + ] + }); + let children = vec![ + rect("#0000ff", 40.0, 40.0, Some(0.0)), + rect("#ff0000", 240.0, 150.0, Some(1.0)), + ]; + let s = scene(cam, children); + let t0 = render_scene_json(s.clone(), 400, 300, 0); + let t2 = render_scene_json(s, 400, 300, 60); + + let (bx0, by0) = channel_centroid(&t0, 400, 300, 2); + let (bx2, by2) = channel_centroid(&t2, 400, 300, 2); + let (rx0, _) = channel_centroid(&t0, 400, 300, 0); + let (rx2, _) = channel_centroid(&t2, 400, 300, 0); + + assert!( + (bx0 - bx2).abs() < 0.5 && (by0 - by2).abs() < 0.5, + "depth-0 plane must not move: ({bx0},{by0}) vs ({bx2},{by2})" + ); + assert!( + (rx0 - rx2 - 100.0).abs() < 2.0, + "depth-1 plane must pan by -100: {rx0} -> {rx2}" + ); + } + + #[test] + fn depth_two_moves_twice_as_much() { + // Camera pans x 0 → 50: depth-1 red moves 50, depth-2 green moves 100. + let cam = serde_json::json!({ + "keyframes": [ + { "property": "x", "values": [ { "time": 0.0, "value": 0.0 }, { "time": 2.0, "value": 50.0 } ] } + ] + }); + let children = vec![ + rect("#ff0000", 200.0, 60.0, Some(1.0)), + rect("#00ff00", 200.0, 180.0, Some(2.0)), + ]; + let s = scene(cam, children); + let t0 = render_scene_json(s.clone(), 400, 300, 0); + let t2 = render_scene_json(s, 400, 300, 60); + + let (rx0, _) = channel_centroid(&t0, 400, 300, 0); + let (rx2, _) = channel_centroid(&t2, 400, 300, 0); + let (gx0, _) = channel_centroid(&t0, 400, 300, 1); + let (gx2, _) = channel_centroid(&t2, 400, 300, 1); + + let red_shift = rx0 - rx2; + let green_shift = gx0 - gx2; + assert!( + (red_shift - 50.0).abs() < 2.0, + "depth 1 must shift by 50, got {red_shift}" + ); + assert!( + (green_shift - 100.0).abs() < 2.0, + "depth 2 must shift by 100 (2x), got {green_shift}" + ); + } + + #[test] + fn depth_one_everywhere_is_byte_identical_to_no_depth() { + // Explicit depth 1.0 on every child (per-plane camera path) must + // produce the same bytes as no depth at all (global camera path). + let cam = serde_json::json!({ "x": 30.0, "y": 10.0, "zoom": 1.5, "rotation": 8.0 }); + let plain = scene( + cam.clone(), + vec![ + rect("#ff0000", 100.0, 60.0, None), + rect("#0000ff", 220.0, 150.0, None), + ], + ); + let with_depth = scene( + cam, + vec![ + rect("#ff0000", 100.0, 60.0, Some(1.0)), + rect("#0000ff", 220.0, 150.0, Some(1.0)), + ], + ); + let a = render_scene_json(plain, 400, 300, 0); + let b = render_scene_json(with_depth, 400, 300, 0); + assert_eq!( + a, b, + "depth 1.0 everywhere must be byte-identical to the global camera path" + ); + } + + #[test] + fn zoom_does_not_scale_locked_plane() { + // Camera zoom 2: the depth-0 blue rect keeps its exact size/position + // (identical pixels to a no-camera render), the depth-1 red grows. + let with_cam = scene( + serde_json::json!({ "zoom": 2.0 }), + vec![ + rect("#0000ff", 20.0, 20.0, Some(0.0)), + rect("#ff0000", 250.0, 160.0, Some(1.0)), + ], + ); + let no_cam = serde_json::json!({ + "duration": 4.0, + "children": [ rect("#0000ff", 20.0, 20.0, Some(0.0)) ] + }); + let buf_cam = render_scene_json(with_cam, 400, 300, 0); + let buf_ref = render_scene_json(no_cam, 400, 300, 0); + + // Blue region (locked plane) identical to the camera-less reference. + let blue = |buf: &[u8]| -> Vec { + let mut out = Vec::new(); + for y in 10..100u32 { + for x in 10..120u32 { + let i = ((y * 400 + x) * 4) as usize; + out.extend_from_slice(&buf[i..i + 4]); + } + } + out + }; + assert_eq!( + blue(&buf_cam), + blue(&buf_ref), + "depth-0 plane must be unaffected by camera zoom" + ); + + // The red rect (depth 1) must be zoomed: with center-origin zoom 2 a + // rect at (250..330, 160..220) maps to (300..460, 170..290) clipped — + // its centroid moves right and its visible area differs from 80x60. + let (rx, _) = channel_centroid(&buf_cam, 400, 300, 0); + assert!( + rx > 330.0, + "depth-1 plane must be zoomed toward bottom-right, centroid x = {rx}" + ); + } +} + +#[cfg(test)] +mod parallax_hitmap_tests { + //! Studio hit-map under per-plane parallax: rects must follow their + //! plane's (depth-scaled) camera transform via the canvas matrix. + + use crate::engine::render::render_scene_hits; + use crate::schema::{Scene, VideoConfig}; + + #[test] + fn hit_rects_follow_their_plane_depth() { + let config: VideoConfig = + serde_json::from_value(serde_json::json!({ "width": 400, "height": 300, "fps": 30 })) + .expect("config"); + // Static camera pan x=100; blue locked (depth 0), red normal (depth 1). + let scene: Scene = serde_json::from_value(serde_json::json!({ + "duration": 4.0, + "camera": { "x": 100.0 }, + "children": [ + { "type": "shape", "shape": "rect", "fill": "#0000ff", + "position": "absolute", "x": 40, "y": 40, + "style": { "width": "80px", "height": "60px", "depth": 0.0 } }, + { "type": "shape", "shape": "rect", "fill": "#ff0000", + "position": "absolute", "x": 240, "y": 150, + "style": { "width": "80px", "height": "60px", "depth": 1.0 } } + ] + })) + .expect("scene"); + + let hits = render_scene_hits(&config, &scene, 0); + assert_eq!(hits.len(), 2, "expected two component hits"); + + // Paint order matches child order: [0] = blue (depth 0), [1] = red. + let blue = &hits[0].rect; + let red = &hits[1].rect; + assert!( + (blue.x - 40.0).abs() < 0.5, + "depth-0 hit rect must ignore the camera pan, x = {}", + blue.x + ); + assert!( + (red.x - 140.0).abs() < 0.5, + "depth-1 hit rect must follow the pan (240 - 100), x = {}", + red.x + ); + } +}