OverlayMotion

Camera motion in Edit Spec v1

Status: partially implemented. The preset subset (preset, amount, time, easing, optional focus) is live at three scopes: overlay.camera (src/player/OverlayCamera.tsx), source.camera for footage-only motion, and spec.camera, the scene scope where source and overlays move as one shot (src/player/SceneCamera.tsx). Beyond the directional presets there is a handheld preset (deterministic wobble; extra fields frequency in cycles per second and seed for phase offset, easing ignored). push-in-out provides a balanced focus move that restores framing before its window ends; push-in-fast-out spends most of its window approaching, then returns quickly. Keyframes, rotationDeg, and crop remain proposed. The examples page (#examples) layers separate focused presenter and quote push-in-out tracks, with handheld motion nested on the quote itself.

Camera motion vs object motion

The one distinction every author and agent must hold: a camera moves the frame around finished content; object motion is the content moving. They never share knobs.

Camera motion Object motion
What moves The viewport (scene, footage, or one overlay's region) The template's own elements (card, text, items)
Who owns it The spec's camera blocks The template
Steered by spec.camera, source.camera, overlay.camera time.appear, reveal, exit, template props
Examples Push in on a face, handheld sway, settle after entrance Card springs up, text types on, list items stagger

Rule of thumb: if the move belongs to a thing ("the card slides in"), it is object motion and lives in the template. If the move belongs to the viewer's eye ("we lean toward the speaker"), it is a camera. A camera never choreographs entrances or exits, and templates never implement camera motion internally.

Three-scope authoring rule

Templates never implement camera motion internally. Quote Card, Tweet Card, Bar Chart, Line Chart, and Stat Counter therefore use the same preset, amount, easing, timing, and normalized final scale behavior.

  • Put camera on an overlay to move only that card.
  • Put camera at the spec root to move the rendered footage and all cards as one complete composition.

Both use camera.time.start for the requested start second and camera.time.duration for motion length. Root time is composition-relative; overlay time is relative to its own overlay window.

Goal

Make push-ins, pull-outs, pans, reframing, and small rotations available to every OverlayMotion composition without putting camera code inside templates.

Camera motion is additive to Edit Spec v1. Existing specs remain valid. A camera block may live at three locations, and its location defines its scope:

  • spec.camera: source and all overlays move as one composed scene.
  • source.camera: footage moves; overlays stay fixed.
  • overlay.camera: one resolved overlay region moves; other layers stay fixed.

Location-based scope avoids selectors and overlay IDs. Multiple scopes may be combined; their transforms nest in the order described below.

Proposed schema

type CameraEasing =
  | "linear"
  | "ease-in"
  | "ease-out"
  | "ease-in-out"
  | { bezier: [number, number, number, number] };

type CameraKeyframe = {
  at: TimeValue;
  focus?: { x: number; y: number }; // percent, defaults to {x: 50, y: 50}
  zoom?: number;                    // defaults to 1
  rotationDeg?: number;             // defaults to 0
  easing?: CameraEasing;            // interpolation leaving this keyframe
};

type CameraBase = {
  time?: TimeWindow;
  easing?: CameraEasing;
  crop?: "cover" | "reveal";
};

type KeyframedCamera = CameraBase & {
  keyframes: CameraKeyframe[];
  preset?: never;
};

type PresetCamera = CameraBase & {
  preset: "push-in" | "push-in-out" | "push-in-fast-out" | "pull-out" | "pan-left" | "pan-right" | "pan-up" | "pan-down";
  amount?: number;
  focus?: { x: number; y: number };
  keyframes?: never;
};

type Camera = KeyframedCamera | PresetCamera;

Add these optional fields to existing Zod objects:

editSpec.camera?: Camera | Camera[];
editSpec.motion?: "full" | "reduced";
videoSource.camera?: Camera | Camera[];
overlay.camera?: Camera;

camera is invalid on audio and none sources. Initial runtime should allow one camera track per scope. A future spec version may add named or overlapping tracks if real use cases require them.

Example

{
  "version": 1,
  "format": "vertical",
  "fps": 30,
  "durationSec": 12,
  "motion": "full",
  "camera": {
    "preset": "push-in",
    "amount": 0.06,
    "time": { "start": "0s", "duration": "12s" },
    "easing": "ease-in-out"
  },
  "source": {
    "type": "video",
    "src": "intro.mp4",
    "camera": {
      "time": { "start": "2s", "duration": "4s" },
      "crop": "cover",
      "keyframes": [
        { "at": "0%", "focus": { "x": 50, "y": 50 }, "zoom": 1 },
        { "at": "100%", "focus": { "x": 72, "y": 42 }, "zoom": 1.25 }
      ]
    }
  },
  "overlays": [
    {
      "template": "stat-counter",
      "region": "corner-tl",
      "time": { "start": "3s", "duration": "4s" },
      "camera": {
        "crop": "reveal",
        "keyframes": [
          { "at": "0%", "zoom": 1.08, "rotationDeg": -1 },
          { "at": "100%", "zoom": 1, "rotationDeg": 0 }
        ]
      },
      "props": { "value": 150, "suffix": "K", "label": "subscribers" }
    }
  ]
}

Time semantics

  • Root and source camera time uses the composition timeline.
  • Overlay camera time uses that overlay's Sequence timeline. 0s means the first frame of the overlay, not the first frame of the composition.
  • camera.time selects a window inside the owner timeline. Without it, the camera window is the full owner timeline.
  • Outside that window, the camera transform is identity. A completed camera window never leaks its final zoom or pan into the following edit.
  • Keyframe at values are local to the camera window. Percentages use camera window duration. Negative seconds count from the end of that window.
  • Values before the first keyframe and after the last keyframe hold the nearest endpoint. Interpolation is clamped.
  • A keyframe's easing controls the segment from that keyframe to the next. The camera-level easing is the fallback.

All resolution uses frames, fps, and pure interpolation. No CSS animation, wall clock, randomness, or runtime measurements may affect rendered frames.

Coordinate and transform semantics

focus is a point in the camera owner's untransformed local box:

  • {x: 0, y: 0} is top-left.
  • {x: 100, y: 100} is bottom-right.
  • Default is {x: 50, y: 50}.

The focus point lands at viewport center. For viewport width W, height H, normalized focus fx, fy, zoom z, and zero rotation:

tx = W / 2 - z * fx * W
ty = H / 2 - z * fy * H

General transform order is:

translate(viewport center)
rotate(rotationDeg)
scale(zoom)
translate(-focus point)

Implement this as a matrix or as ordered transforms on a dedicated wrapper. Do not write the transform onto a template root: templates own their internal entrance, exit, and item transforms.

Scope viewport:

  • Root: composition dimensions.
  • Source: actual footage slot, including a slot inside wrapsVideo templates.
  • Overlay: resolved region box.

Render nesting:

composition clip
  root camera
    source camera
      source
    overlay region placement
      overlay camera
        template internal motion

When a template wraps video, its source camera stays immediately around the source passed to that template. Root and overlay nesting remains unchanged.

Crop and edge policy

crop: "cover" clips to the scope viewport and prevents empty edges. Runtime must clamp focus and, when required, raise effective minimum zoom. Rotation must use the rotated content bounds when computing the minimum cover scale.

crop: "reveal" still clips to the viewport but allows background or transparent gaps inside it. This is useful when an overlay intentionally flies or drifts out of its region.

Defaults:

  • Root and video source: cover.
  • Overlay: reveal.

Validation should warn when cover materially changes requested focus or zoom. The exact adjusted values must remain deterministic at every frame.

Presets

Presets are authoring sugar, never separate rendering behavior. Compiler expands each preset into validated keyframes before evaluation.

  • push-in: zoom from 1 to 1 + amount.
  • pull-out: zoom from 1 + amount to 1.
  • pan-left/right/up/down: move focus by amount * 100 percentage points.

Default amount is 0.1. Preset direction describes camera attention: a pan-right raises focus x, so pixels move left. Preset focus defaults to center.

Reduced motion

motion: "reduced" freezes each camera at its first resolved keyframe. Server renders require the explicit spec value. Site player may offer a UI default from prefers-reduced-motion, but must compile it into this explicit input so player and CLI output remain reproducible.

This first camera milestone does not silently alter templates' existing internal motion. A later contract can make templates consume the same preference.

Validation

  • At least two keyframes for a keyframed camera.
  • Resolved keyframe times must be strictly increasing and inside camera window.
  • Focus values must be finite and between 0 and 100.
  • Zoom must be finite and greater than 0; recommended public range is 0.25 to 8.
  • Rotation must be finite; recommended public range is -45 to 45 degrees.
  • Custom Bezier values must be finite; x control points must be between 0 and 1.
  • Reject a camera object containing both preset and keyframes.
  • Reject source camera on audio or none.

Hard limits protect the public API. Warnings cover visually risky but valid requests such as strong rotation, extreme zoom, or heavy cover clamping.

Implementation plan

  1. Schema and compiler
    • Add camera Zod schemas in src/spec/types.ts.
    • Add pure preset expansion, time resolution, easing, and matrix evaluation in src/spec/camera.ts.
  2. Renderer
    • Add a transform-only CameraViewport in src/player/CameraViewport.tsx.
    • Integrate it at root, source, and overlay boundaries in SpecRenderer.tsx.
    • Keep OffthreadVideo and template code unchanged.
  3. Authoring
    • Show camera blocks in the site's copyable spec JSON.
    • Add preset examples and validation messages.
  4. Hardening
    • Add rotation-aware cover math and visual fixtures for all formats.

Acceptance tests

  • Existing Edit Spec v1 examples parse and render unchanged.
  • Keyframe evaluation matches exact expected matrices at start, middle, and end.
  • Equivalent 30 fps and 60 fps renders reach the same time-based values.
  • Root, source, and overlay scopes affect only their documented layers.
  • Nested transforms work when a template has wrapsVideo.
  • Cover never exposes an edge across vertical, horizontal, and square formats.
  • Reveal permits gaps while still clipping outside its viewport.
  • Template-owned transforms keep working inside an overlay camera.
  • Reduced motion freezes camera identically in Player, Studio, and CLI render.
  • Player and server render produce matching frames for the same spec.

Open decisions

  • Ship rotation in the first runtime milestone or validate it as experimental.
  • Expose reveal publicly at launch or reserve it for overlay motion.
  • Add multiple named camera tracks only after a concrete overlap use case exists.