Skip to content

Camera: a world larger than the frame, per-segment easing, handheld shake, camera-aware validation #428

Description

@LeadcodeDev

Context

User feedback from a real production (a 15 s one-take product film, 1920×1080, hand-written scenario). These are the blockers that made them keep an HTML/browser-based renderer for that kind of film.

To do

  • Free camera over a world larger than the frame. Today the scene camera only shows what is inside the 1920×1080 frame: panning to an element placed at x: 3000 shows an empty frame, and validate rejects the element as a viewport overflow. The world view steps from beat to beat with a crossfade, so an object cannot travel from one beat to the next. Wanted: a scene whose world extends beyond the frame, a camera that moves through it, and elements that travel continuously across it.
    {"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"}}]}]}
    Expected at t=2: the pink square in the centre. Actual: empty frame, and validate reports a viewport overflow.
  • Camera-aware validation: an element outside the frame but brought into view by the camera is not an overflow.
  • Per-keyframe easing on camera keyframes, like component keyframes (today one easing per camera property for the whole scene).
  • Handheld shake: a wiggle/shake applicable to the camera.

Definition of done

  • A test that fails on current main is written first. For anything visual it is a render test (scenario → frame → pixel assertions); tests that build AnimatedProperties by hand are not enough — that is exactly how the regressions listed here slipped through.
  • Each item is verified end to end: scenario JSON, rustmotion validate, render, image inspected.
  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace pass.
  • Vocabulary changes update crates/rustmotion/skills/SKILL.md, the relevant rules/ file and the exported schema. A field the validator accepts must have an effect, otherwise the validator rejects it or warns.
  • No comments in Rust code (repository rule). Commits follow <type>: <verb> <message>. No AI attribution in commits, PRs or issue comments.

Activity

  1. LeadcodeDev commented on Sep 29, 2026

    @LeadcodeDev
    OwnerAuthor

    Root cause of the first item, found while implementing #427 and verified at 1920×1080. I had filed it separately as #451 before noticing it is this item; closing that one as a duplicate and keeping the diagnosis here.

    The viewport clip is issued after the camera transform is already on the canvas, in crates/rustmotion/src/engine/render/scene.rs (two sites, around lines 490 and 1518):

    let camera_guard = match effective_camera(scene) {
        Some(camera) if plane_cam.is_none() => {
            let g = super::CanvasGuard::new(canvas);
            apply_camera_transform(canvas, scene, camera, time as f32, w, h);
            Some(g)
        }
        _ => 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);

    Rect::from_wh(width, height) is in local coordinates, and the canvas matrix already carries the camera translation, so the clip rectangle travels with the camera instead of staying pinned to the surface.

    Reproduction: 1920×1080, camera x: 0 → -3000 over 2 s, a red rectangle at local x: 200 and a green one at local x: -2800, so the green one lands at screen x: 200 when the pan completes. Counting sampled pixels of each colour in rustmotion still:

    time camera x red green
    0.0 0 1900 0
    1.0 -1500 1026 0
    2.0 -3000 0 0

    At time: 1.0 the clip sits at device [1500, 3420], so red — at screen 1700..2100 — survives only over 1700..1920, 55% of it; 1026/1900 is 54%. At time: 2.0 the clip is at [3000, 4920], entirely off a 1920-wide surface, and the frame is blank although the green rectangle is at screen 200..600.

    So "panning to an element placed at x: 3000 shows an empty frame" is not a missing feature — the content is drawn and then clipped away. The clip is the surface, so it belongs before the camera transform, or expressed in device coordinates. That is separable from the other half of the item (validate rejecting the element as a viewport overflow, which is the third checkbox's camera-aware validation).

    camera.motion_blur from #427 inherits the same ceiling, since each sub-frame exposure goes through the same path.

  2. added a commit that references this issue on Sep 29, 2026
    6d7d66a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions