diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index a1be5f6..c04c329 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -911,6 +911,60 @@ pub struct Camera { pub motion_blur: Option, } +impl Camera { + pub fn resolve_property(&self, property: &str, time: f64) -> f32 { + let track = self + .keyframes + .iter() + .find(|k| k.property == property) + .filter(|t| !t.values.is_empty()); + let Some(track) = track else { + return self.static_property(property); + }; + + let points = &track.values; + if time <= points[0].time { + return points[0].value; + } + if time >= points[points.len() - 1].time { + return points[points.len() - 1].value; + } + for i in 0..points.len() - 1 { + let p0 = &points[i]; + let p1 = &points[i + 1]; + if time >= p0.time && time <= p1.time { + let segment_duration = p1.time - p0.time; + let segment_t = if segment_duration.abs() < 1e-9 { + 1.0 + } else { + (time - p0.time) / segment_duration + }; + let segment_easing = p0.easing.as_ref().unwrap_or(&track.easing); + let eased = crate::engine::animator::ease(segment_t, segment_easing) as f32; + return p0.value + (p1.value - p0.value) * eased; + } + } + points[points.len() - 1].value + } + + fn static_property(&self, property: &str) -> f32 { + match property { + "x" => self.x, + "y" => self.y, + "zoom" => self.zoom, + "rotation" => self.rotation, + "focus" => self.focus, + "aperture" => self.aperture, + "rotate_x" => self.rotate_x, + "rotate_y" => self.rotate_y, + "perspective" => self.perspective, + "origin.x" => self.origin.as_ref().map(|o| o.x).unwrap_or(0.0), + "origin.y" => self.origin.as_ref().map(|o| o.y).unwrap_or(0.0), + _ => 0.0, + } + } +} + /// Shutter-window camera motion blur, opt-in via `camera.motion_blur`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -1059,6 +1113,11 @@ pub struct CameraKeyframePoint { pub time: f64, /// Value at this time. pub value: f32, + /// Easing for the segment starting at this keyframe, overriding the + /// track-level `easing` for that segment only — same convention as a + /// component `Keyframe`. + #[serde(default)] + pub easing: Option, } fn default_camera_focus() -> f32 { @@ -2127,3 +2186,71 @@ mod scene_shake_and_flash_tests { assert!(matches!(scene.effects[1], PostEffect::Grain { .. })); } } + +#[cfg(test)] +mod camera_keyframe_easing_tests { + use super::*; + + fn camera_with_x_track(easing_on_first_point: Option) -> Camera { + let json = serde_json::json!({ + "keyframes": [{ + "property": "x", + "easing": "linear", + "values": [ + { "time": 0.0, "value": 0.0, "easing": easing_on_first_point }, + { "time": 1.0, "value": 100.0 } + ] + }] + }); + serde_json::from_value(json).expect("camera with one x track deserializes") + } + + #[test] + fn a_keyframes_own_easing_overrides_the_track_easing_for_its_segment() { + let linear = camera_with_x_track(None); + let linear_mid = linear.resolve_property("x", 0.5); + assert!( + (linear_mid - 50.0).abs() < 1e-4, + "with no per-keyframe easing the track's own linear easing must still apply, \ + got {linear_mid}" + ); + + let eased = camera_with_x_track(Some(EasingType::EaseIn)); + let eased_mid = eased.resolve_property("x", 0.5); + assert!( + (eased_mid - 12.5).abs() < 1e-4, + "a per-keyframe `ease_in` on the segment's starting point must override the \ + track's `linear` for that segment only (ease_in_cubic(0.5) = 0.125, so \ + 0 + 100 * 0.125 = 12.5), got {eased_mid}" + ); + } + + #[test] + fn per_keyframe_easing_only_affects_the_segment_it_starts() { + let json = serde_json::json!({ + "keyframes": [{ + "property": "x", + "easing": "linear", + "values": [ + { "time": 0.0, "value": 0.0, "easing": "ease_in" }, + { "time": 1.0, "value": 100.0 }, + { "time": 2.0, "value": 200.0 } + ] + }] + }); + let camera: Camera = serde_json::from_value(json).expect("camera deserializes"); + + let first_segment_mid = camera.resolve_property("x", 0.5); + assert!( + (first_segment_mid - 12.5).abs() < 1e-4, + "the first segment must still use its own ease_in, got {first_segment_mid}" + ); + + let second_segment_mid = camera.resolve_property("x", 1.5); + assert!( + (second_segment_mid - 150.0).abs() < 1e-4, + "the second segment has no easing of its own and must fall back to the track's \ + linear easing, not inherit ease_in from the first segment, got {second_segment_mid}" + ); + } +} diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 57d393b..b7c10f8 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -1638,7 +1638,7 @@ Scenes support a virtual camera with animatable pan, zoom, and rotation. | `y` | f32 | `0.0` | Camera center Y offset (pixels) | | `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" }]` | +| `keyframes` | array | `[]` | `[{ "property", "values": [{ "time", "value", "easing"? }], "easing" }]` — a point's own `easing` governs the segment starting at it, see [camera-3d.md](rules/camera-3d.md) | | `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` diff --git a/crates/rustmotion/skills/rules/camera-3d.md b/crates/rustmotion/skills/rules/camera-3d.md index 99160e3..d830745 100644 --- a/crates/rustmotion/skills/rules/camera-3d.md +++ b/crates/rustmotion/skills/rules/camera-3d.md @@ -24,6 +24,20 @@ as a camera but as cards each spinning in its own corner. All three animate through `keyframes`, like `zoom` and `rotation`. +Each point in `values` may carry its own `easing`, which governs the segment **starting** at that point and overrides the track's own `easing` for that segment alone — the same convention a component `Keyframe` already uses: + +```json +{ "property": "x", "easing": "linear", "values": [ + { "time": 0, "value": 0, "easing": "ease_in" }, + { "time": 1, "value": 900 }, + { "time": 2, "value": 1200 } +] } +``` + +The first segment eases in, the second falls back to the track's `linear`. With no per-point easing anywhere, the track-level one applies to every segment, exactly as before. + +> `"easing": "spring"` on a camera keyframe is accepted and resolves as **linear**. A component `Animation` drives a real spring from its own `spring` config; `CameraKeyframe` has no such field, so there is nothing to drive one. This is not new — it was already true of the track-level easing. + ## One vanishing point, and depth-scaled rotation Perspective is applied **once**, around the camera's origin. That is the whole @@ -43,6 +57,30 @@ Putting `depth` on a node buried in a subtree does not tilt it on its own. skews without converging. For a camera that reads as a camera you need a distance, and `1200`–`1800` covers most framings. +## The clip is pinned to the frame, the camera is not + +The viewport clip does not travel with the camera. An element placed anywhere — including far outside `0..width` / `0..height` — is legitimate content a pan can bring into view, the way a real camera moves through a set larger than what it frames at any moment: + +```json +{ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30, "background": "#101018" }, + "scenes": [{ + "duration": 2, + "camera": { "keyframes": [{ "property": "x", "easing": "linear", + "values": [{ "time": 0, "value": 0 }, { "time": 2, "value": 2100 }] }] }, + "children": [{ + "type": "div", "position": "absolute", "x": 3000, "y": 440, + "style": { "width": 200, "height": 200, "background": "#FF3366" } + }] + }] +} +``` + +At `t = 2` the element is centred on screen. Until #428 it was not: `clip_rect` was issued *after* the camera transform, and Skia bakes a clip into device space using the matrix in force when it is called — so the clip travelled with the camera and, past a pan of roughly the frame's own width, left the surface entirely and rendered a blank frame. + +`validate` agrees with the renderer on this, and does not report such an element as a viewport overflow as long as the camera reaches it. See [rules/geometry-safety.md](geometry-safety.md). + ## See also Camera motion blur (#362) is now implemented — `camera.motion_blur`, see diff --git a/crates/rustmotion/skills/rules/camera-motion-blur.md b/crates/rustmotion/skills/rules/camera-motion-blur.md index d3d0414..e7c0166 100644 --- a/crates/rustmotion/skills/rules/camera-motion-blur.md +++ b/crates/rustmotion/skills/rules/camera-motion-blur.md @@ -48,6 +48,7 @@ Practical consequence: do not combine a fast `camera.motion_blur` with a compone - **`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). +- **`scene.shake`** — a declarative, beat-synced handheld or impact wobble, added to the camera pose on top of `camera.keyframes` (`camera_pose_at`). It works with no `camera` block at all. Reach for it for a shaky-cam feel; `camera.motion_blur` only blurs motion that is already happening, pan or shake, and creates none of its own. - **`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 diff --git a/crates/rustmotion/skills/rules/geometry-safety.md b/crates/rustmotion/skills/rules/geometry-safety.md index c559592..bbe6eac 100644 --- a/crates/rustmotion/skills/rules/geometry-safety.md +++ b/crates/rustmotion/skills/rules/geometry-safety.md @@ -66,6 +66,30 @@ rustmotion validate scenario.json --lenient # warnings only Position/size clamping (`viewport_overflow`) is never auto-applied — fix those by hand too. +## A camera makes "outside the viewport" a question about time + +An element placed outside `0..width` / `0..height` is not automatically an overflow when the scene has a `camera`. The validator resolves the camera's own `keyframes` — not just its static `x`/`y`/`zoom` — and samples them across the scene, stopping at `scene.freeze_at`. The element is reported only if **no** sampled instant of that motion brings it fully into frame. + +```json +{ + "version": "1.0", + "video": { "width": 1920, "height": 1080, "fps": 30, "background": "#101018" }, + "scenes": [{ + "duration": 2, + "camera": { "keyframes": [{ "property": "x", "easing": "linear", + "values": [{ "time": 0, "value": 0 }, { "time": 2, "value": 2100 }] }] }, + "children": [{ + "type": "div", "position": "absolute", "x": 3000, "y": 440, + "style": { "width": 200, "height": 200, "background": "#FF3366" } + }] + }] +} +``` + +This passes: the element is never inside `0..1920` on its own, but at `t = 2` the camera has panned to `x = 2100` and the element is centred. A camera that only ever reaches `x = 500`, with the element at `x = 9000`, is still reported — a camera on the scene is not a blanket exemption. + +Two limits worth knowing. The check asks whether the element is in frame at *some* instant, not at every instant: an element that leaves the frame mid-pan is not reported by the default pass, and `--strict-anim` is what samples per frame. And the camera fold accounts for `x`, `y` and `zoom` only — `rotation` and an animated `origin` are not folded in, so an element brought into view purely by a camera rotation is still reported. + ## When to use what | Symptom | Fix | diff --git a/crates/rustmotion/src/cli/commands/geometry.rs b/crates/rustmotion/src/cli/commands/geometry.rs index 3e4a390..1dbb2d8 100644 --- a/crates/rustmotion/src/cli/commands/geometry.rs +++ b/crates/rustmotion/src/cli/commands/geometry.rs @@ -100,6 +100,9 @@ pub fn validate_geometry(scenario: &ResolvedScenario) -> Vec .map(|(_, _, w, h)| (w, h)); let path_root = format!("views[{}].scenes[{}]", vi, si); + let sample_until = scene + .freeze_at + .map_or(scene.duration, |f| f.clamp(0.0, scene.duration)); walk( &children, &built.root.children, @@ -111,6 +114,7 @@ pub fn validate_geometry(scenario: &ResolvedScenario) -> Vec Some(&raw_indices), false, camera, + sample_until, root_bound, &mut violations, ); @@ -150,6 +154,7 @@ fn walk( path_indices: Option<&[usize]>, parent_clips: bool, camera: Option<&Camera>, + sample_until: f64, container_bound: Option<(f32, f32)>, out: &mut Vec, ) { @@ -170,11 +175,12 @@ fn walk( if !is_exempted(&child.component) { 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); + let vbbox = apply_static_node_transform(&raw_bbox, &box_node.css, viewport_f); + let (vbbox, in_view) = + camera_fold_over_scene(&vbbox, camera, viewport_f, sample_until); + if !in_view { + check_viewport(&child.component, &child_path, &vbbox, viewport, vi, si, out); } - check_viewport(&child.component, &child_path, &vbbox, viewport, vi, si, out); } if !parent_clips && !container_clips(&child.component) { check_unwrappable_text( @@ -214,6 +220,7 @@ fn walk( None, parent_clips || container_clips(&child.component), camera, + sample_until, Some((cw, ch)), out, ); @@ -506,15 +513,17 @@ fn apply_transform_chain( (x, y) } -fn fold_static_camera(bbox: &BBox, camera: &Camera, viewport: (f32, f32)) -> BBox { - let zoom = camera.zoom; +fn fold_camera_at(bbox: &BBox, camera: &Camera, viewport: (f32, f32), time: f64) -> BBox { + let zoom = camera.resolve_property("zoom", time); + let cam_x = camera.resolve_property("x", time); + let cam_y = camera.resolve_property("y", time); let (cx, cy) = camera .origin .as_ref() .map(|o| (o.x, o.y)) .unwrap_or((viewport.0 / 2.0, viewport.1 / 2.0)); - let new_x = zoom * bbox.x + (1.0 - zoom) * cx - zoom * camera.x; - let new_y = zoom * bbox.y + (1.0 - zoom) * cy - zoom * camera.y; + let new_x = zoom * bbox.x + (1.0 - zoom) * cx - zoom * cam_x; + let new_y = zoom * bbox.y + (1.0 - zoom) * cy - zoom * cam_y; BBox { x: new_x, y: new_y, @@ -523,6 +532,34 @@ fn fold_static_camera(bbox: &BBox, camera: &Camera, viewport: (f32, f32)) -> BBo } } +fn bbox_overflows(bbox: &BBox, viewport: (f32, f32)) -> bool { + let eps = 0.5; + bbox.x < -eps + || bbox.y < -eps + || bbox.x + bbox.w > viewport.0 + eps + || bbox.y + bbox.h > viewport.1 + eps +} + +fn camera_fold_over_scene( + bbox: &BBox, + camera: Option<&Camera>, + viewport: (f32, f32), + sample_until: f64, +) -> (BBox, bool) { + let Some(camera) = camera else { + return (*bbox, !bbox_overflows(bbox, viewport)); + }; + let mut closest_miss = fold_camera_at(bbox, camera, viewport, 0.0); + for time in anim_sample_times(sample_until) { + let folded = fold_camera_at(bbox, camera, viewport, time); + if !bbox_overflows(&folded, viewport) { + return (folded, true); + } + closest_miss = folded; + } + (closest_miss, false) +} + fn check_viewport( component: &Component, path: &str, @@ -1085,7 +1122,7 @@ fn walk_anim( transformed = scale_bbox_from_own_center(&transformed, 1.0 + overshoot); } if let Some(cam) = camera { - transformed = fold_static_camera(&transformed, cam, viewport_f); + transformed = fold_camera_at(&transformed, cam, viewport_f, time); } let vw = viewport.0 as f32; let vh = viewport.1 as f32; @@ -2079,6 +2116,68 @@ mod tests { ); } + #[test] + fn a_camera_pan_that_reaches_an_out_of_frame_element_is_not_reported() { + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 2.0, + "camera": { + "keyframes": [{ "property": "x", "easing": "linear", "values": [ + { "time": 0, "value": 0 }, { "time": 2, "value": 2100 } + ] }] + }, + "children": [{ + "type": "div", + "position": "absolute", + "x": 3000, "y": 440, + "style": { "width": 200, "height": 200, "background": "#FF3366" } + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations.iter().all(|v| v.component != "div"), + "the camera reaches x=2100 by t=2, which puts a local x=3000 element on screen \ + at x≈900..1100 — a viewport-overflow report at any sampled camera time is wrong \ + here since the element is in frame at the pan's own endpoint: {:?}", + violations + ); + } + + #[test] + fn a_camera_pan_that_never_reaches_an_out_of_frame_element_still_reports_it() { + let json = r##"{ + "video": { "width": 1920, "height": 1080 }, + "scenes": [{ + "duration": 2.0, + "camera": { + "keyframes": [{ "property": "x", "easing": "linear", "values": [ + { "time": 0, "value": 0 }, { "time": 2, "value": 500 } + ] }] + }, + "children": [{ + "type": "div", + "position": "absolute", + "x": 9000, "y": 440, + "style": { "width": 200, "height": 200, "background": "#FF3366" } + }] + }] + }"##; + let scenario = parse(json); + let violations = validate_geometry(&scenario); + assert!( + violations + .iter() + .any(|v| v.component == "div" && v.kind == ViolationKind::ViewportOverflow), + "the camera only ever reaches x=500, which never brings a local x=9000 element \ + into a 1920-wide frame at any sampled instant — this must still be reported, not \ + silently waved through just because a camera exists on the scene: {:?}", + violations + ); + } + #[test] fn strict_anim_detects_slide_in_overflow() { let json = r##"{ diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 058ddae..7a9bf2d 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -470,6 +470,13 @@ fn render_frame_v2_scaled_core( camera: plane_cam, }; + let clip_guard = super::CanvasGuard::new(canvas); + canvas.clip_rect( + Rect::from_wh(config.width as f32, config.height as f32), + ClipOp::Intersect, + true, + ); + let camera_guard = match effective_camera(scene) { Some(camera) if plane_cam.is_none() => { let g = super::CanvasGuard::new(canvas); @@ -486,13 +493,6 @@ fn render_frame_v2_scaled_core( _ => None, }; - let clip_guard = super::CanvasGuard::new(canvas); - canvas.clip_rect( - Rect::from_wh(config.width as f32, config.height as f32), - ClipOp::Intersect, - true, - ); - render_with_new_pipeline( canvas, root_children, @@ -503,8 +503,8 @@ fn render_frame_v2_scaled_core( scene, ); - drop(clip_guard); drop(camera_guard); + drop(clip_guard); let row_bytes = scaled_w as usize * 4; let mut pixels = vec![0u8; row_bytes * scaled_h as usize]; @@ -1502,6 +1502,13 @@ pub fn render_scene_fg_scaled( camera: plane_cam, }; + canvas.save(); + canvas.clip_rect( + Rect::from_wh(config.width as f32, config.height as f32), + ClipOp::Intersect, + true, + ); + let has_camera = effective_camera(scene).is_some() && plane_cam.is_none(); if let (Some(camera), None) = (effective_camera(scene), plane_cam) { apply_camera_transform( @@ -1514,12 +1521,6 @@ pub fn render_scene_fg_scaled( ); } - canvas.save(); - canvas.clip_rect( - Rect::from_wh(config.width as f32, config.height as f32), - ClipOp::Intersect, - true, - ); render_with_new_pipeline( canvas, &children, @@ -1529,11 +1530,11 @@ pub fn render_scene_fg_scaled( &ctx, scene, ); - canvas.restore(); if has_camera { canvas.restore(); } + canvas.restore(); let row_bytes = scaled_w as usize * 4; let mut pixels = vec![0u8; row_bytes * scaled_h as usize]; @@ -1551,55 +1552,7 @@ pub fn render_scene_fg_scaled( } pub(super) fn interpolate_camera_property(camera: &Camera, property: &str, time: f32) -> f32 { - use crate::engine::animator::ease; - - let track = camera.keyframes.iter().find(|k| k.property == property); - let track = match track { - Some(t) if !t.values.is_empty() => t, - _ => { - return match property { - "x" => camera.x, - "y" => camera.y, - "zoom" => camera.zoom, - "rotation" => camera.rotation, - "focus" => camera.focus, - "aperture" => camera.aperture, - "rotate_x" => camera.rotate_x, - "rotate_y" => camera.rotate_y, - "perspective" => camera.perspective, - "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, - }; - } - }; - - let points = &track.values; - let t = time as f64; - - if t <= points[0].time { - return points[0].value; - } - - if t >= points[points.len() - 1].time { - return points[points.len() - 1].value; - } - - for i in 0..points.len() - 1 { - let p0 = &points[i]; - let p1 = &points[i + 1]; - if t >= p0.time && t <= p1.time { - let segment_t = if (p1.time - p0.time).abs() < 1e-9 { - 1.0 - } else { - (t - p0.time) / (p1.time - p0.time) - }; - let eased = ease(segment_t, &track.easing) as f32; - return p0.value + (p1.value - p0.value) * eased; - } - } - - points[points.len() - 1].value + camera.resolve_property(property, time as f64) } pub(super) fn resolve_camera_origin( @@ -2030,3 +1983,190 @@ mod ghost_in_flow_placement_tests { ); } } + +#[cfg(test)] +mod camera_wide_world_tests { + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + + const W: usize = 1920; + const H: usize = 1080; + + fn far_element_scenario() -> crate::schema::ResolvedScenario { + let json = format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {W}, "height": {H}, "fps": 30, "background": "#101018" }}, + "scenes": [{{ + "duration": 2, + "camera": {{ + "keyframes": [{{ "property": "x", "easing": "linear", "values": [ + {{ "time": 0, "value": 0 }}, {{ "time": 2, "value": 2100 }} + ] }}] + }}, + "children": [ + {{ "type": "div", "position": "absolute", "x": 3000, "y": 440, + "style": {{ "width": 200, "height": 200, "background": "#FF3366" }} }} + ] + }}] + }}"## + ); + crate::loader::load_scenario_from_source(None, Some(&json)).expect("load") + } + + fn frame_at(scenario: &crate::schema::ResolvedScenario, frame_in_scene: u32) -> Vec { + let tasks = build_frame_tasks(scenario); + let task = tasks + .iter() + .find(|task| { + matches!( + task, + FrameTask::Normal { + scene_idx: 0, + frame_in_scene: f, + .. + } if *f == frame_in_scene + ) + }) + .unwrap_or_else(|| { + panic!("a frame task for scene 0 frame {frame_in_scene} must exist") + }); + render_frame_task(&scenario.video, scenario, task).expect("render") + } + + fn rgb_at(frame: &[u8], x: usize, y: usize) -> (u8, u8, u8) { + let i = (y * W + x) * 4; + (frame[i], frame[i + 1], frame[i + 2]) + } + + #[test] + fn a_camera_pan_across_a_world_wider_than_the_frame_brings_a_distant_element_into_view() { + let scenario = far_element_scenario(); + + let at_start = frame_at(&scenario, 0); + let (r0, g0, b0) = rgb_at(&at_start, 1000, 540); + assert!( + r0 < 60 && g0 < 60 && b0 < 60, + "before the pan the element (local x=3000) is nowhere near a camera at x=0 on a \ + 1920-wide frame — the probe point must read as plain background, got \ + rgb=({r0},{g0},{b0})" + ); + + let at_end = frame_at(&scenario, 59); + let (r1, g1, b1) = rgb_at(&at_end, 1000, 540); + assert!( + r1 > 200 && g1 < 100 && b1 > 60 && b1 < 160, + "once the camera has panned to x≈2100, the element (local x=3000) lands on \ + screen at x≈900..1100 — the probe point at (1000,540) must read the element's \ + own #FF3366, not background; got rgb=({r1},{g1},{b1})" + ); + } +} + +#[cfg(test)] +mod handheld_shake_composes_with_camera_tests { + use crate::encode::video::{build_frame_tasks, render_frame_task, FrameTask}; + + const W: usize = 1920; + const H: usize = 1080; + + fn scenario_with(camera_json: &str) -> crate::schema::ResolvedScenario { + let json = format!( + r##"{{ + "version": "1.0", + "video": {{ "width": {W}, "height": {H}, "fps": 30, "background": "#101018" }}, + "scenes": [{{ + "duration": 1.0{camera_json}, + "shake": {{ + "impacts": [{{ "at": 0.5, "amplitude": 40.0 }}], + "decay": 5.0, + "frequency": 8.0 + }}, + "children": [ + {{ "type": "div", "position": "absolute", "x": 860, "y": 440, + "style": {{ "width": 200, "height": 200, "background": "#33CCFF" }} }} + ] + }}] + }}"## + ); + crate::loader::load_scenario_from_source(None, Some(&json)).expect("load") + } + + fn frame_at(scenario: &crate::schema::ResolvedScenario, frame_in_scene: u32) -> Vec { + let tasks = build_frame_tasks(scenario); + let task = tasks + .iter() + .find(|task| { + matches!( + task, + FrameTask::Normal { + scene_idx: 0, + frame_in_scene: f, + .. + } if *f == frame_in_scene + ) + }) + .unwrap_or_else(|| { + panic!("a frame task for scene 0 frame {frame_in_scene} must exist") + }); + render_frame_task(&scenario.video, scenario, task).expect("render") + } + + fn rgb_at(frame: &[u8], x: usize, y: usize) -> (u8, u8, u8) { + let i = (y * W + x) * 4; + (frame[i], frame[i + 1], frame[i + 2]) + } + + fn is_background(rgb: (u8, u8, u8)) -> bool { + rgb.0 < 40 && rgb.1 < 40 && rgb.2 < 50 + } + + #[test] + fn scene_shake_alone_moves_content_with_no_camera_block_declared() { + let scenario = scenario_with(""); + + let before_impact = frame_at(&scenario, 0); + let probe_before = rgb_at(&before_impact, 1040, 540); + assert!( + !is_background(probe_before), + "at t=0 (well before the impact at t=0.5) the element must sit at its own \ + undisturbed position, covering the probe point; got rgb={probe_before:?}" + ); + + let at_impact = frame_at(&scenario, 15); + let probe_at_impact = rgb_at(&at_impact, 1040, 540); + assert!( + is_background(probe_at_impact), + "`scene.shake` alone, with no `camera` block at all, must still move the \ + content — at the impact's own peak (t=0.5) the element has shifted left by its \ + 40px amplitude and must have uncovered this probe point; got \ + rgb={probe_at_impact:?}" + ); + } + + #[test] + fn scene_shake_adds_to_an_existing_camera_pan_instead_of_being_ignored() { + let scenario = scenario_with( + r##", "camera": { "keyframes": [{ "property": "x", "easing": "linear", "values": [ + { "time": 0, "value": 0 }, { "time": 1, "value": 300 } + ] }] }"##, + ); + + let just_before_impact = frame_at(&scenario, 14); + let probe_before = rgb_at(&just_before_impact, 900, 540); + assert!( + !is_background(probe_before), + "one frame before the impact, only the pan has moved the element — the probe \ + point at the pan-only edge must still be covered; got rgb={probe_before:?}" + ); + + let at_impact = frame_at(&scenario, 15); + let probe_at_impact = rgb_at(&at_impact, 900, 540); + assert!( + is_background(probe_at_impact), + "one frame later, the shake's own impact lands on top of the ongoing pan — the \ + sudden extra 40px must uncover a point that the pan alone was still covering, \ + proving shake adds to the camera pan rather than being overridden by it; got \ + rgb={probe_at_impact:?}" + ); + } +}