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
cameraon an overlay to move only that card. - Put
cameraat 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
Sequencetimeline.0smeans the first frame of the overlay, not the first frame of the composition. camera.timeselects 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
atvalues 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
easingcontrols 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 * HGeneral 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
wrapsVideotemplates. - Overlay: resolved region box.
Render nesting:
composition clip
root camera
source camera
source
overlay region placement
overlay camera
template internal motionWhen 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 from1to1 + amount.pull-out: zoom from1 + amountto1.pan-left/right/up/down: move focus byamount * 100percentage 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
0and100. - Zoom must be finite and greater than
0; recommended public range is0.25to8. - Rotation must be finite; recommended public range is
-45to45degrees. - Custom Bezier values must be finite; x control points must be between
0and1. - Reject a camera object containing both
presetandkeyframes. - Reject source camera on
audioornone.
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
- 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.
- Add camera Zod schemas in
- Renderer
- Add a transform-only
CameraViewportinsrc/player/CameraViewport.tsx. - Integrate it at root, source, and overlay boundaries in
SpecRenderer.tsx. - Keep
OffthreadVideoand template code unchanged.
- Add a transform-only
- Authoring
- Show camera blocks in the site's copyable spec JSON.
- Add preset examples and validation messages.
- 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
revealpublicly at launch or reserve it for overlay motion. - Add multiple named camera tracks only after a concrete overlap use case exists.
