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
127 changes: 127 additions & 0 deletions crates/rustmotion-core/src/schema/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,60 @@ pub struct Camera {
pub motion_blur: Option<CameraMotionBlurConfig>,
}

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)]
Expand Down Expand Up @@ -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<EasingType>,
}

fn default_camera_focus() -> f32 {
Expand Down Expand Up @@ -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<EasingType>) -> 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}"
);
}
}
2 changes: 1 addition & 1 deletion crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
38 changes: 38 additions & 0 deletions crates/rustmotion/skills/rules/camera-3d.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/rustmotion/skills/rules/camera-motion-blur.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions crates/rustmotion/skills/rules/geometry-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading
Loading