greenlight-motion 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,18 +4,26 @@
4
4
  > here. `frontend/src/…` paths are files of the engine's public source repository. MotionFast templates (`mediaType: "motion"`)
5
5
  > stay in GreenLight Dash.
6
6
 
7
- # GreenLight Dash — Camera layers, paths, rigs and presets
7
+ # GreenLight Dash — Camera layers, depth of field, paths, rigs and presets
8
8
 
9
9
  > Read [video-projects.md](video-projects.md) for the shared project, clip, channel, timing and versioned-write contracts. Load only the sections needed for the task.
10
10
 
11
11
  ## Contents
12
12
 
13
- - [Camera layers (`mediaType: "camera"`) — a virtual camera over the whole stack](video-camera.md#camera-layers-mediatype-camera--a-virtual-camera-over-the-whole-stack)
14
- - [A sequence's own camera](video-camera.md#a-sequences-own-camera)
15
- - [Smart Zoom — the camera's Zoom mode](video-camera.md#smart-zoom--the-cameras-zoom-mode)
16
- - [Drawn cursor](video-camera.md#drawn-cursor--a-screen-recording-with-no-cursor-in-its-picture)
17
- - [Camera presets (save & load reusable camera moves)](video-camera.md#camera-presets-save--load-reusable-camera-moves)
18
- - [Camera Rig — a parametric camera move](video-camera.md#camera-rig--a-parametric-camera-move)
13
+ - [Camera layers (`mediaType: "camera"`) — a virtual camera over the whole stack](#camera-layers-mediatype-camera--a-virtual-camera-over-the-whole-stack)
14
+ - [Camera properties](#camera-properties)
15
+ - [Camera Motion Paths (`cameraPath` + `camPathT`)](#camera-motion-paths-camerapath--campatht)
16
+ - [Depth of field](#depth-of-field) — per layer for flat layers, per pixel for model layers, the cyclorama and occluding plates
17
+ - [Screen recordings' camera](#screen-recordings-camera)
18
+ - [A sequence's own camera](#a-sequences-own-camera)
19
+ - [Smart Zoom — the camera's Zoom mode](#smart-zoom--the-cameras-zoom-mode)
20
+ - [Drawn cursor](#drawn-cursor--a-screen-recording-with-no-cursor-in-its-picture)
21
+ - [Camera presets (save & load reusable camera moves)](#camera-presets-save--load-reusable-camera-moves)
22
+ - [Built-in camera moves](#built-in-camera-moves)
23
+ - [Camera Rig — a parametric camera move](#camera-rig--a-parametric-camera-move)
24
+ - [Rig parameters](#rig-parameters)
25
+ - [Built-in rigs](#built-in-rigs)
26
+ - [Mode precedence](#mode-precedence)
19
27
 
20
28
  ### Camera layers (`mediaType: "camera"`) — a virtual camera over the whole stack
21
29
 
@@ -23,12 +31,13 @@ A camera clip draws nothing. While the playhead is inside it, it poses a virtual
23
31
 
24
32
  Rules:
25
33
 
26
- - The camera lives on a channel marked `"kind": "camera"`. **A timeline may have at most one camera channel, and it must be LAST in its `channels` array** (the project's, and each sequence's for its own camera — see below) (channel order is z-order; last renders highest). The editor re-pins it on every write and on load, so a project authored with the camera elsewhere is silently reordered — emit it last to avoid surprising diffs. Put several camera clips on it to chain moves; the clip under the playhead wins, and outside every camera clip the camera is neutral.
27
- - Camera clips are synthetic (no `url`) and are keyframed with their own property registry, using the ordinary `propertyKeyframes` shape (positions 0–100 % of the clip).
34
+ - The camera lives on a channel marked `"kind": "camera"`. **A timeline may have at most one camera channel, and it must be LAST in its `channels` array** (the project's, and each sequence's for its own camera — see [A sequence's own camera](#a-sequences-own-camera)); channel order is z-order, so last renders highest. The editor re-pins it on every write and on load, so a project authored with the camera elsewhere is silently reordered — emit it last to avoid surprising diffs. Put several camera clips on it to chain moves; the clip under the playhead wins, and outside every camera clip the camera is neutral.
35
+ - Camera clips are synthetic (no `url`) and are keyframed with their own property registry (below), using the ordinary `propertyKeyframes` shape: `position` 0–100 % of the clip, `value` a string.
28
36
  - Any channel can opt out with `"cameraLocked": true` — it renders exactly as if there were no camera (watermarks, fixed captions).
37
+ - In a 3D Scene the Camera layer films the world too, depth of field included, unless the Scene names its own `rig.scene.camera` ([video-world3d.md](video-world3d.md#turn-it-on-and-write-a-scene)).
29
38
  - A camera-locked layer is a 2D layer, as in After Effects: it composites in STACK order and never depth-tests against 3D. Above a model layer in the stack it draws over the model (a flash, a title at the comp plane needs no pulling toward the camera); at the very bottom of the stack (a full-frame backdrop) it stays behind everything, the Scene Rig's cyclorama included. 3D layers on either side of a locked layer still occlude each other by depth.
30
39
 
31
- ```json
40
+ ```jsonc
32
41
  {
33
42
  "clips": [
34
43
  { "id": "cam1", "source": "LOCAL", "mediaType": "camera", "url": "",
@@ -50,7 +59,9 @@ Rules:
50
59
  }
51
60
  ```
52
61
 
53
- Camera properties (the `CAMERA_KF_PROPS` registry in `frontend/src/components/videoEditor/projectCamera.js`):
62
+ #### Camera properties
63
+
64
+ Every field below can be a **base value on the camera item** (`item.camAperture = 40`) or a keyframe track in `propertyKeyframes`; a track wins at the playhead.
54
65
 
55
66
  | Property | Unit | Neutral | Meaning |
56
67
  | --- | --- | --- | --- |
@@ -58,31 +69,40 @@ Camera properties (the `CAMERA_KF_PROPS` registry in `frontend/src/components/vi
58
69
  | `camX` | % of frame width | `0` | Positive pans the camera **right**, so content slides left. |
59
70
  | `camY` | % of frame height | `0` | Positive pans the camera **down**, so content slides up. |
60
71
  | `camRoll` | deg | `0` | Cants the horizon clockwise (dutch angle). |
61
- | `camDolly` | px | `0` | Moves the viewpoint in/out. Unlike `camZoom` this DOES change perspective — layers at different `panZ`/`translateZ` separate. |
62
- | `camTiltX` / `camTiltY` | deg | `0` | Pitch / yaw. |
72
+ | `camDolly` | px | `0` | Moves the viewpoint in (positive) or out. Unlike `camZoom` this DOES change perspective — layers at different `panZ`/`translateZ` separate. |
73
+ | `camTiltX` / `camTiltY` | deg | `0` | Pitch / yaw, any angle. Past ±90° the camera orbits behind the comp plane and sees the layers from the back, mirrored. |
63
74
  | `camDofEnabled` | 0/1 | `0` | Depth-of-field master switch. |
64
75
  | `camFocusDistance` | px | `0` | Distance to the sharp plane. `0` = the comp plane (`(H/2)/tan(14°)`), so it survives a canvas resize. |
65
- | `camAperture` | px | `24` | Larger = shallower. `0` disables the blur entirely. |
76
+ | `camAperture` | px | `24` | The blur's radius in px (not its width) for something far behind the focus plane, at `camBlurLevel` 100. Larger = shallower. `0` disables the blur entirely. |
66
77
  | `camBlurLevel` | % | `100` | Scales the computed circle of confusion. `0` disables. |
67
78
  | `camHighlightGain` | % | `0` | Bloom on bright pixels, so highlights form bokeh discs. |
68
79
  | `camFocusX` / `camFocusY` | % | `50` | Focus point. Also the centre of the focus area when one is set. |
69
80
  | `camFocusWidth` | % of frame width | `0` | `0` = the point is only a depth probe. Above `0` a screen-space region around the point stays sharp and eases to the layer's full blur by its edge. |
70
- | `camPathT` | % | `0` | Progress along `cameraPath`; active only while that path has at least two points and `follow: true`. |
81
+ | `camPathT` | % | `0` | Progress along `cameraPath` or a Camera Rig's move (0–100). |
71
82
  | `camPathOffsetX/Y/Z` | px | `0` | Constant displacement from `cameraPath`, on world axes (+X right, +Y **down**, +Z toward the viewer). The aim is computed **after** it, so a camera pushed off a shared path still faces its target. No-op unless the path is live. |
83
+ | `camFollowTarget` | 1-based index | `1` | Which `cameraRig.targets` entry a `follow` rig looks at (see [Camera Rig](#camera-rig--a-parametric-camera-move)). |
84
+ | `camLensK1` | — | `0` | Base value only. A real wide lens's one radial distortion term (x_bent = x · (1 + k1 · r²), x in focal lengths; negative = barrel), as a footage solve finds it (tracking-data.md (GreenLight Dash only)). The 3D Scene's picture is bent by it so it lines up with footage shot through that lens; other layers are not. Keep the solve's value; `0` or absent bends nothing. |
85
+
86
+ Animating `camDolly` and `camZoom` in opposite directions is the vertigo effect.
72
87
 
73
88
  #### Camera Motion Paths (`cameraPath` + `camPathT`)
74
89
 
75
- A camera item can ride a saved 3D bezier spline. It uses the same world-pixel point/handle convention as an object Motion Path: origin at comp centre, `x` right, `y` up, `z` toward the viewer; `hIn`/`hOut` are offsets from the anchor and `manual: true` marks them hand-shaped. The same rule applies — **write both handles and `manual: true` on every anchor**; an anchor with no handles is a corner the renderer flies straight through, since nothing auto-smooths a stored path outside the interactive world view. Use `hOut = (P[next] − P[prev]) / 6`, `hIn = −hOut` for a smooth anchor (ends of an open path: `(P[1] − P[0]) / 3` and `−(P[last] − P[last−1]) / 3`).
90
+ A camera item can ride a saved 3D bezier spline. It uses the same world-pixel point/handle convention as an [object Motion Path](video-paths.md#object-motion-paths-motionpath--motionpatht): origin at comp centre, `x` right, `y` up, `z` toward the viewer; `hIn`/`hOut` are offsets from the anchor. **Write both handles and `manual: true` on every anchor** — an anchor with no handles is a corner the renderer flies straight through, since nothing auto-smooths a stored path but a hand edit in the editor. Compute them with the [tangent rule](video-shapes.md#writing-the-handles) (each handle also carries `z`).
76
91
 
77
92
  ```json
78
93
  {
94
+ "cameraMoveMode": "path",
79
95
  "cameraPath": {
80
96
  "points": [
81
- { "x": -700, "y": 120, "z": 1850 },
97
+ { "x": -700, "y": 120, "z": 1850,
98
+ "hIn": { "x": -233.3, "y": 13.3, "z": 133.3 },
99
+ "hOut": { "x": 233.3, "y": -13.3, "z": -133.3 }, "manual": true },
82
100
  { "x": 0, "y": 80, "z": 1450,
83
101
  "hIn": { "x": -220, "y": 0, "z": 120 },
84
102
  "hOut": { "x": 220, "y": 0, "z": -120 }, "manual": true },
85
- { "x": 620, "y": -100, "z": 1100 }
103
+ { "x": 620, "y": -100, "z": 1100,
104
+ "hIn": { "x": -206.7, "y": 60, "z": 116.7 },
105
+ "hOut": { "x": 206.7, "y": -60, "z": -116.7 }, "manual": true }
86
106
  ],
87
107
  "closed": false,
88
108
  "follow": true,
@@ -99,20 +119,33 @@ A camera item can ride a saved 3D bezier spline. It uses the same world-pixel po
99
119
  }
100
120
  ```
101
121
 
102
- - A camera path is live only with at least two points and `follow: true`. Provide a `camPathT` progress track (a linear 0→100 is the sensible default) unless the camera is intentionally parked on one point.
103
- - `camPathT` is 0–100. Open paths run first→last; closed paths wrap 100 back to point one.
104
- - `aim: "tangent"` looks along the curve. `aim: "target"` looks at `targetId`, or the composition centre when `targetId` is null/missing. `aim: "free"` leaves orientation to `camTiltX`, `camTiltY`, and `camRoll` base values/keyframes.
105
- - Following owns the camera's position on the curve. Lens zoom and depth-of-field remain independent; free aim also keeps the manual orientation tracks authoritative.
122
+ - **A path drives the camera only while `cameraMoveMode` is `"path"` or absent**, and the path is live: at least two points and `follow: true`, or one point on `aim: "captured"`, which holds the camera there. An explicit `"free"`, `"rig"` or `"smartZoom"` ignores the path. Applying a camera preset that animates the pose writes `"free"`, so set `"cameraMoveMode": "path"` whenever you add a path to an existing camera.
123
+ - Provide a `camPathT` progress track (a `Linear` 0→100 is the sensible default) unless the camera is intentionally parked on one point. `camPathT` is 0–100; open paths run first→last, closed paths wrap 100 back to point one.
124
+ - `aim: "tangent"` looks along the curve. `aim: "target"` looks at `targetId` (a timeline item id), or the composition centre when `targetId` is null/missing. `aim: "free"` leaves orientation to `camTiltX`, `camTiltY`, and `camRoll` base values/keyframes. `aim: "captured"` reads it from each point: `rx`, `ry`, `rz` are Tilt X, Tilt Y and Roll in degrees, interpolated between points, and the `camTilt`/`camRoll` tracks are ignored.
125
+ - **Timing a path by points** — segments are walked at a uniform rate, so point `i` of `n` is reached where `camPathT` equals `i / (n - 1) * 100` (`i / n * 100` when closed). To land on a point at a chosen moment, key that value at that clip position, with `"easing": "Linear"`.
126
+ - Following owns the camera's position on the curve. Lens zoom and depth of field remain independent; free aim also keeps the manual orientation tracks authoritative.
106
127
  - **Offset from path** — `camPathOffsetX/Y/Z` (table above). Two cameras, or a camera and a layer, can then ride one shared curve at a fixed distance apart. Keyframable like every other camera field.
107
- - **Reusing another curve**: layer motion paths and camera paths share one world-pixel space, so `points`/`closed` transfer literally between them. See [Object Motion Paths](video-paths.md#object-motion-paths-motionpath--motionpatht) for converting a shape outline.
128
+ - **Reusing another curve**: layer motion paths and camera paths share one world-pixel space, so `points`/`closed` transfer literally between them.
108
129
  - `keyframesPerPoint` / `keyframesPerPointCount` / `keyframesPerPointClosed` are the same "one key per anchor" bookkeeping the object path uses: one evenly-spaced `camPathT` key per anchor (a closed path adding the final 100 key back to anchor one), so the camera reaches each point on a keyframe and the pacing between points can be shaped per segment. Omit it and author any valid progress track directly.
109
130
  - The path lives on the camera **item**, so it trims, duplicates, saves, undoes, and renders with that camera clip.
110
131
 
111
- Animating `camDolly` and `camZoom` in opposite directions is the vertigo effect.
132
+ #### Depth of field
112
133
 
113
- **Depth of field** is physical and per layer: each layer's circle of confusion comes from its own distance to the camera (`aperture · |focusDistance − layerDistance| / layerDistance · blurLevel/100`), so a rack focus is just keyframed `camFocusDistance`. Both distances are **view depth from the camera**, so a camera that moves carries the sharp plane with it: under a dolly the subject at depth `z` (its `panZ`) sits at `perspectiveDistance − camDolly − z` (`perspectiveDistance = (H/2)/tan(14°)`, ≈ 2166 on a 1080-tall frame). To keep a subject sharp through a move, key `camFocusDistance` on the dolly's own keys with that value; under a rig, with the subject's depth along the rig's view at a few points of the move (the scene script does both for `dof.focus`). All the DOF fields above may be set either as a **base value on the camera item** (`item.camAperture = 40`) or as a keyframe track in `propertyKeyframes` — tracks win at the playhead. `camAperture: 0` or `camBlurLevel: 0` renders byte-identically to DOF off. There are no per-layer `dof*` fields — a project still carrying them animates nothing. Rendered by **`/render-onserver`** and the desktop render; the fast `/render` ignores camera layers along with everything else non-trivial.
134
+ Depth of field is physical and needs `camDofEnabled: 1`. Each layer's circle of confusion comes from its distance `d` to the camera, as a blur radius in px: `CoC = camAperture · |camFocusDistance − d| / d · camBlurLevel/100`, capped at 64 px. A rack focus is therefore just a keyframed `camFocusDistance`.
114
135
 
115
- **Screen recordings get a camera clip on the camera channel.** A new recording gets a **Smart Zoom** camera, a clip named `Smart Zoom`; see the Smart Zoom section below. Projects made before it carry a clip named `Auto-zoom` with baked `camZoom`/`camX`/`camY` keys. So for "make the zoom less aggressive", change the Smart Zoom preset, or edit `camZoom` keys on an `Auto-zoom` clip. Never hunt for an adjustment layer.
136
+ - **Flat layers** (text, shapes, pictures, video) take one CoC per layer: the depth of their plane.
137
+ - **Model layers, the Scene Rig's cyclorama and occluding shadow plates blur per pixel.** They hand the compositor their depth buffer, and every pixel blurs by the CoC of its own distance (capped at 64 px, still held sharp inside the focus area). A character in focus stays sharp in the same model as a wall metres behind it, so a whole set, backdrop included, can live in one model layer.
138
+ - **The gather is depth-aware:** a blurred background never smears over a sharp foreground edge, a sharp foreground leaves no halo on the blurred background, and a foreground in front of the focus plane blurs out over what is behind it and past its own silhouette. Taps read the picture prefiltered, so thin bright detail under a wide blur (lettering far behind the focus) averages instead of sparkling; flat layers get the same.
139
+ - **Both distances are view depth from the camera**, so a camera that moves carries the sharp plane with it. Under a dolly the subject at depth `z` (its `panZ`) sits at `perspectiveDistance − camDolly − z` (`perspectiveDistance = (H/2)/tan(14°)`, ≈ 2166 on a 1080-tall frame). To keep a subject sharp through a move, key `camFocusDistance` on the dolly's own keys with that value; under a rig, key it with the subject's depth along the rig's view at a few points of the move (a GreenLight Motion scene script's `dof.focus` does both).
140
+ - **In a 3D Scene** the same numbers set Final's thin lens, so Final blurs as Draft does (see [video-world3d.md](video-world3d.md#turn-it-on-and-write-a-scene)).
141
+ - **Matching a real lens.** For a lens of focal length `f` mm at f-number `N` on a sensor `S` mm wide, with the focus `F` in px (a composition pixel is a millimetre of a 3D Scene) and the frame `W` px wide: `camAperture = f² · W / (2 · N · (F − f) · S)`, at `camBlurLevel` 100. Past the field's 300, raise `camBlurLevel` instead (a `.blend` import keys it this way).
142
+ - **Focus area:** `camFocusWidth` above 0 keeps a screen region around `camFocusX`/`camFocusY` sharp whatever its depth, easing to the full blur by its edge.
143
+ - `camAperture: 0` or `camBlurLevel: 0` renders byte-identically to DOF off. There are no per-layer `dof*` fields — `dof*` fields on a layer animate nothing. The Focus presets in [Built-in camera moves](#built-in-camera-moves) are ready rack-focus moves.
144
+ - Rendered by **`/render-onserver`** and the desktop render; the fast `/render` ignores camera layers along with everything else non-trivial.
145
+
146
+ #### Screen recordings' camera
147
+
148
+ A screen recording gets a **Smart Zoom** camera, a clip named `Smart Zoom` (see [Smart Zoom](#smart-zoom--the-cameras-zoom-mode)). A recording's camera can instead be a clip named `Auto-zoom` with baked `camZoom`/`camX`/`camY` keys. So for "make the zoom less aggressive", change the Smart Zoom preset, or edit `camZoom` keys on an `Auto-zoom` clip. Never hunt for an adjustment layer.
116
149
 
117
150
  ### A sequence's own camera
118
151
 
@@ -126,11 +159,11 @@ channel camera-locked so a zoom or an orbit never shows its edge.
126
159
 
127
160
  ### Smart Zoom — the camera's Zoom mode
128
161
 
129
- The Movement tab **Zoom** zooms the camera in over **zoom ranges** on the camera layer. They are automatic from a
130
- screen recording's clicks, or added by hand. Nothing is baked into keyframes:
162
+ Smart Zoom (`"cameraMoveMode": "smartZoom"`) zooms the camera in over **zoom ranges** on the camera layer. They are
163
+ automatic from a screen recording's clicks, or placed by hand. Nothing is baked into keyframes:
131
164
 
132
165
  ```jsonc
133
- "cameraMoveMode": "smartZoom", // the Zoom tab (the stored id keeps its first name)
166
+ "cameraMoveMode": "smartZoom",
134
167
  "smartZoom": {
135
168
  "clipId": "<the recording's CLIP id>", // null: no recording, the ranges are placed by hand
136
169
  "preset": "studio", // studio|calm|precise|lively|follow
@@ -143,27 +176,20 @@ screen recording's clicks, or added by hand. Nothing is baked into keyframes:
143
176
 
144
177
  - **Ranges:**
145
178
  - `startMs`/`endMs` are in the **recording's own time** (source ms, so trims and moves keep a zoom on its moment).
146
- With no recording they are the camera clip's own time.
179
+ With no recording they are the camera clip's own time. A range shorter than 100 ms is dropped.
147
180
  - `zoom` is 1–4; absent = the preset's (Studio 1.45).
148
181
  - `mode: "follow"` aims at the clicks inside the range. `mode: "fixed"` centres the view exactly on `point` (0..1 of
149
182
  the frame).
150
183
  - `disabled: true` keeps a range but skips it.
151
184
  - Writing `ranges` replaces the automatic list. Copy the automatic ones first (ids `r1`, `r2`…) to keep them.
152
- - **In the editor:**
153
- - **Add Zoom** (a timeline toolbar toggle, shown while a camera is in Zoom): with it ON, hovering the camera layer
154
- shows a ghost zoom with a + and a click adds one there; a block drags to move it. With it OFF (the default) the
155
- camera layer drags, trims and selects like any clip, and only the blocks answer: a click picks one.
156
- - Either way, dragging a block's end resizes it and × removes it.
157
- - With a block picked, dragging the preview moves the zoomed view and scroll or pinch zooms; both make the range
158
- fixed. Delete removes it.
159
- - The panel has the Feel (preset), the picked zoom's value, Follow the clicks again, and Back to automatic zooms.
185
+ Removing `ranges` goes back to the automatic zooms.
160
186
 
161
187
  - **How it decides:**
162
188
  - It reads the recording clip's `recordingMeta` (its clicks and cursor track).
163
189
  - It zooms in from 0.3 s before a click until 2.5 s after the last one, and moves part of the way toward each group of clicks.
164
190
  - Every move is a spring, so the camera never stops and restarts between clicks.
165
191
  - **It follows the recording's items on the timeline.** Trims, splits, moves and speed changes re-aim it on their own. Outside the recording's items, the camera rests at zoom 1, centred. It only acts inside its own camera clip's span.
166
- - **Presets** (`smartZoom.js` `PRESETS`; an unknown id reads as `studio`):
192
+ - **Presets** (an unknown id reads as `studio`):
167
193
 
168
194
  | Preset | Look |
169
195
  |---|---|
@@ -173,12 +199,12 @@ screen recording's clicks, or added by hand. Nothing is baked into keyframes:
173
199
  | `lively` | 1.7×, quick |
174
200
  | `follow` | 1.5×, follows the cursor, not only clicks |
175
201
 
176
- The recorder's setup panel picks one before the take. Its **No zoom** choice makes no camera at all.
202
+ The recorder sets one before the take; a take recorded with no zoom gets no camera at all.
177
203
  - **Precedence:** it drives `camZoom`, `camX` and `camY`, so keys on those three render as nothing. `camRoll`, `camDolly`, tilt and depth of field compose as usual. A motion preset that animates zoom or pan flips the mode to `"free"`.
178
204
  - **Changing it:**
179
205
  - Patch `smartZoom.preset` on the live project to change the look, or `smartZoom.ranges` to place zooms.
180
206
  - Set `"cameraMoveMode": "free"` to drive the camera by hand. `smartZoom` stays on the item, so setting the mode
181
- back (the Zoom tab) restores it.
207
+ back to `"smartZoom"` restores it.
182
208
  - A recording clip without `recordingMeta` (no Input Monitoring) has no automatic zooms, but ranges you add still
183
209
  work.
184
210
 
@@ -206,7 +232,7 @@ mockups, transitions and the camera treat it like a recorded cursor. Three field
206
232
  - `tilt`: 0–40.
207
233
  - `motionBlur`: 0–2; 0 = none.
208
234
  - **Only for `recordingMeta.cursorInVideo === false`.** A recording that has the cursor in its picture (Windows,
209
- Linux, Window capture, older takes) ignores `recordingCursor`; the cursor it has can't be removed.
235
+ Linux, Window capture) ignores `recordingCursor`; the cursor it has can't be removed.
210
236
  - The cursor lands exactly on each click, presses on mouseDown and takes the real shape (hand over links, I-beam
211
237
  over text) from the track. A missing or failed `.glcursor` falls back to the light path in `recordingMeta`.
212
238
  - To change it, edit the live project: GET, set the item's `recordingCursor`, PUT with `expected_version`.
@@ -224,34 +250,65 @@ Global JSON files under `media/camera-presets/` — a separate directory from `v
224
250
  "base": { "camDofEnabled": 1, "camAperture": 40, "camFocusWidth": 35 } }
225
251
  ```
226
252
 
227
- `base` is optional and holds the preset's **un-keyframed** camera settings — depth of
228
- field is normally dialled in as static values rather than animated, so saving only
229
- `keyframes` would lose the whole look. On apply, `base` is written onto the camera item
230
- as base fields while `keyframes` become its `propertyKeyframes`; a property that is
231
- keyframed never appears in `base` (its track already carries it). A preset may have
232
- `base` and no `keyframes` — a pure lens/DOF setup with no motion.
253
+ `base` is optional and holds the preset's **un-keyframed** camera settings — depth of field is normally dialled in as static values rather than animated, so saving only `keyframes` would lose the whole look. On apply, `base` is written onto the camera item as base fields while `keyframes` become its `propertyKeyframes`; a property that is keyframed never appears in `base` (its track already carries it). A preset may have `base` and an empty `keyframes` list — a pure lens/DOF setup with no motion.
233
254
 
234
255
  Endpoints:
235
256
 
236
257
  ```
237
258
  GET /api/camera-presets → { "presets": [{ "id", "name", "description", "duration", "keyframes", "base", "updated_at" }] }
238
- POST /api/camera-presets { "name", "duration?", "keyframes", "base?" } # needs keyframes OR base
259
+ POST /api/camera-presets { "name", "duration?", "keyframes", "base?" } # keyframes is required: send [] for a base-only preset
239
260
  DELETE /api/camera-presets/{preset_id}
240
261
  ```
241
262
 
242
- Keyframe `position` is 0–100 % of the preset's own `duration`. To apply one: expand each snapshot into per-property tracks on a camera item (`{position, value, easing}` per property). Because positions are percentages, a preset spreads across whatever length the camera clip already has — applying one never needs to resize it.
263
+ Keyframe `position` is 0–100 % of the preset's own `duration`. To apply one: expand each snapshot into per-property tracks on a camera item (`{position, value, easing}` per property, plus `bezier` when the easing is `Custom`). Because positions are percentages, a preset spreads across whatever length the camera clip already has — applying one never needs to resize it.
264
+
265
+ **Applying a pose preset also sets `"cameraMoveMode": "free"`.** A Camera Rig, or a live Motion Path, overwrites the very props the preset animates (see [Mode precedence](#mode-precedence)), so a preset applied on top of one renders as nothing. Set the mode alongside the tracks whenever the preset touches `camX`, `camY`, `camDolly`, `camTiltX`, `camTiltY` or `camRoll`; leave the mode alone for a preset that only animates depth of field or `camPathT`, which compose with a rig. `cameraRig` and `cameraPath` stay on the item, so the mode flip is reversible.
243
266
 
244
- **Applying a pose preset also sets `"cameraMoveMode": "free"`.** A Camera Rig, or a Motion Path with `follow: true`, overwrites the very props the preset animates (see the precedence note under Camera Rig), so a preset applied on top of one renders as nothing. Set the mode alongside the tracks whenever the preset touches `camX`, `camY`, `camDolly`, `camTiltX`, `camTiltY` or `camRoll`; leave the mode alone for a **Focus** preset (DOF only) or one that drives `camPathT`, which compose with a rig. `cameraRig` and `cameraPath` are never cleared — the mode flip is reversible.
267
+ A preset key may carry a `bezier: [x1,y1,x2,y2]` alongside `easing: "Custom"` — a per-segment curve. Saving a camera as a preset and applying it both keep the curve, so a hand-shaped camera move round-trips instead of degrading to `Smooth`.
245
268
 
246
- 24 built-in moves live in `CAMERA_MOTION_PRESETS` (`cameraMotionPresets.js`, common-snapshot shape, ids prefixed `cm-`), grouped **Cinematic** (`cm-quiet-push`, `cm-swoop-dive`, `cm-corkscrew`, `cm-fast-orbit`, `cm-lateral-glide`, `cm-parallax-arc`, `cm-arc-closeup`, `cm-grand-reveal`), **Editorial** (`cm-slingshot`, `cm-step-zoom`, `cm-handheld-breath`, `cm-soft-land`, `cm-documentary-follow`), **Energy** (`cm-weave-run`, `cm-s-curve-drift`, `cm-macro-sweep`, `cm-punch-through`), **Loops** (`cm-orbit-pulse`, `cm-float`, `cm-figure-eight` — first and last snapshots match, so they loop) and **Focus** (`cm-rack-to-near`, `cm-rack-to-far`, `cm-focus-chase`, `cm-macro-bloom` — DOF tracks only, no motion props, so they compose with the camera's static pose). Read the exact snapshots from that file. `CAMERA_PRESETS` in `projectCamera.js` seeds the keyframe-builder registry and is not a move catalog.
269
+ #### Built-in camera moves
247
270
 
248
- A preset key may carry a `bezier: [x1,y1,x2,y2]` alongside `easing: 'Custom'` — a per-segment curve. `commonToPropertyKeyframes` and `cameraItemToPreset` pass it through, so a hand-shaped camera move round-trips into a preset and back instead of degrading to Smooth. `cm-punch-through` (8.7 s, dolly only) is the built-in that uses one: a linear creep from dolly 98 to 235 over the first 71 %, then one custom segment that detonates to 2600. On a 1080-tall project `perspectiveDistance ≈ 2166`, so the last instant flies **through** the comp plane and the frame blows out — that is the move, not a bug; park layers on negative `panZ` to stop the camera short of them.
271
+ The editor's 24 built-in moves, as compact snapshots. Each line is `id`, category, duration in seconds and its keys as `[position, easing of the segment after the key, values set at this key]` (plus `{"bezier": […]}` before the values on a `Custom` key). To turn one into tracks:
249
272
 
250
- An entry may set `sparse: true`, which drops the usual "seed every motion prop" expansion so the preset writes tracks ONLY for the props it animates — it then composes with the pose the camera already holds instead of resetting it. `cm-punch-through` uses it (one `camDolly` track, nothing else); every other motion preset seeds the full pose.
273
+ 1. **Values carry forward:** each key holds every value set at it or at an earlier key.
274
+ 2. **The first key resets the pose:** unless the move is `sparse`, a move that animates any of `camZoom`, `camX`, `camY`, `camRoll`, `camDolly`, `camTiltX`, `camTiltY` first sets all seven to neutral (`camZoom` 1, the rest 0), so it starts from a clean pose. A depth-of-field field it uses starts from its neutral in the [Camera properties](#camera-properties) table.
275
+ 3. **A key whose easing is `null` uses `Smooth`** (on the last key it plays no segment).
276
+ 4. Write every value as a string, one track per property with a key at every position, and set `"cameraMoveMode": "free"` when the move touches the pose (above).
277
+
278
+ ```jsonc
279
+ {"id": "cm-quiet-push", "cat": "Cinematic", "dur": 6, "k": [[0, "AE Smooth", {"camZoom": 1.02, "camY": 1}], [100, null, {"camZoom": 1.16, "camDolly": 80, "camY": 0}]]}
280
+ {"id": "cm-swoop-dive", "cat": "Cinematic", "dur": 4.5, "k": [[0, "Expo Out", {"camY": 9, "camTiltX": -14, "camZoom": 1.35, "camDolly": -120, "camRoll": -3}], [100, null, {"camY": -3, "camTiltX": 3, "camZoom": 1.1, "camDolly": 160, "camRoll": 0}]]}
281
+ {"id": "cm-corkscrew", "cat": "Cinematic", "dur": 5, "k": [[0, "AE Smooth", {"camRoll": -22, "camTiltY": -10, "camZoom": 1.6, "camX": -5, "camY": -3}], [100, null, {"camRoll": 0, "camTiltY": 4, "camZoom": 1.08, "camX": 1, "camY": 0}]]}
282
+ {"id": "cm-fast-orbit", "cat": "Cinematic", "dur": 3, "k": [[0, "Linear", {"camTiltY": -20, "camX": -8, "camZoom": 1.12}], [100, null, {"camTiltY": 20, "camX": 8, "camZoom": 1.12}]]}
283
+ {"id": "cm-lateral-glide", "cat": "Cinematic", "dur": 6, "k": [[0, "Smooth", {"camX": -7, "camY": 0.8, "camTiltY": -1.8, "camZoom": 1.08}], [50, "Smooth", {"camX": 0, "camY": 0, "camTiltY": 0, "camZoom": 1.1}], [100, null, {"camX": 7, "camY": -0.8, "camTiltY": 1.8, "camZoom": 1.12}]]}
284
+ {"id": "cm-parallax-arc", "cat": "Cinematic", "dur": 7, "k": [[0, "Smooth", {"camX": -8, "camTiltY": -4.5, "camDolly": -80, "camZoom": 1.08}], [50, "Smooth", {"camX": 0, "camTiltY": 0, "camDolly": 170, "camZoom": 1.13}], [100, null, {"camX": 8, "camTiltY": 4.5, "camDolly": -80, "camZoom": 1.08}]]}
285
+ {"id": "cm-arc-closeup", "cat": "Cinematic", "dur": 6, "k": [[0, "Cubic Out", {"camX": -11, "camY": 2, "camTiltY": -9, "camDolly": -140, "camZoom": 0.96}], [30, "Smooth", {"camX": -4, "camY": 1, "camTiltY": -4, "camDolly": 40, "camZoom": 1.06}], [65, "Smooth", {"camX": 5, "camY": -1, "camTiltY": 2, "camDolly": 230, "camZoom": 1.26}], [100, null, {"camX": 1.5, "camY": 0, "camTiltY": 3, "camDolly": 160, "camZoom": 1.16}]]}
286
+ {"id": "cm-grand-reveal", "cat": "Cinematic", "dur": 3.5, "k": [[0, "Linear", {"camZoom": 2.3, "camRoll": -5, "camY": -4, "camDolly": 220}], [55, "Smooth", {}], [100, null, {"camZoom": 1, "camRoll": 0, "camY": 0, "camDolly": 0}]]}
287
+ {"id": "cm-slingshot", "cat": "Editorial", "dur": 1.8, "k": [[0, "Impulse", {"camZoom": 1.05}], [100, null, {"camZoom": 1.75, "camDolly": 260, "camY": -1}]]}
288
+ {"id": "cm-step-zoom", "cat": "Editorial", "dur": 2.4, "k": [[0, "Hold", {}], [25, "Hold", {"camZoom": 1.16, "camX": 2}], [50, "Hold", {"camZoom": 1.32, "camX": -1.5}], [75, "Hold", {"camZoom": 1.5, "camX": 0}], [100, null, {}]]}
289
+ {"id": "cm-handheld-breath", "cat": "Editorial", "dur": 8, "k": [[0, "Sine Smooth", {"camX": -0.4, "camY": 0.4, "camRoll": -0.15, "camZoom": 1.05}], [19, "Sine Smooth", {"camX": 1.1, "camY": -0.6, "camRoll": 0.3, "camZoom": 1.062}], [43, "Sine Smooth", {"camX": -0.8, "camY": 0.9, "camRoll": -0.25, "camZoom": 1.054}], [68, "Sine Smooth", {"camX": 0.6, "camY": 0.2, "camRoll": 0.2, "camZoom": 1.068}], [87, "Sine Smooth", {"camX": -0.9, "camY": -0.5, "camRoll": -0.22, "camZoom": 1.058}], [100, null, {"camX": -0.4, "camY": 0.4, "camRoll": -0.15, "camZoom": 1.05}]]}
290
+ {"id": "cm-soft-land", "cat": "Editorial", "dur": 4.2, "k": [[0, "Expo Out", {"camY": 9, "camTiltX": -8, "camDolly": -130, "camZoom": 0.94, "camRoll": -2}], [60, "Smooth", {"camY": -0.6, "camTiltX": 1.2, "camDolly": 30, "camZoom": 1.04, "camRoll": 0.4}], [100, null, {"camY": 0, "camTiltX": 0.6, "camDolly": 0, "camZoom": 1.01, "camRoll": 0}]]}
291
+ {"id": "cm-documentary-follow", "cat": "Editorial", "dur": 5, "k": [[0, "Smooth", {"camX": -3, "camY": 1.5, "camRoll": -0.15, "camZoom": 1.06}], [38, "Smooth", {"camX": 1, "camY": -0.5, "camRoll": 0.25, "camZoom": 1.1}], [72, "Smooth", {"camX": -0.5, "camY": 0.8, "camRoll": -0.1, "camZoom": 1.08}], [100, null, {"camX": 2.5, "camY": 0, "camRoll": 0.1, "camZoom": 1.12}]]}
292
+ {"id": "cm-weave-run", "cat": "Energy", "dur": 5.5, "k": [[0, "Sine Smooth", {"camX": -8, "camRoll": -3, "camDolly": -150, "camZoom": 1.1, "camTiltY": -4}], [30, "Sine Smooth", {"camX": 6, "camRoll": 3, "camDolly": 0, "camTiltY": 3}], [60, "Sine Smooth", {"camX": -5, "camRoll": -2.5, "camDolly": 140, "camTiltY": -3}], [100, null, {"camX": 7, "camRoll": 2, "camDolly": 300, "camTiltY": 4}]]}
293
+ {"id": "cm-s-curve-drift", "cat": "Energy", "dur": 6.5, "k": [[0, "Smooth", {"camX": -10, "camY": 3, "camTiltY": -6, "camDolly": -90, "camZoom": 1.05, "camRoll": -1}], [33, "Smooth", {"camX": 4, "camY": -1.5, "camTiltY": 1, "camDolly": 60, "camZoom": 1.12, "camRoll": 0.5}], [66, "Smooth", {"camX": -3, "camY": 1, "camTiltY": -2, "camDolly": 160, "camZoom": 1.18, "camRoll": -0.5}], [100, null, {"camX": 10, "camY": -2, "camTiltY": 5, "camDolly": 90, "camZoom": 1.1, "camRoll": 1}]]}
294
+ {"id": "cm-macro-sweep", "cat": "Energy", "dur": 5, "k": [[0, "Smooth", {"camZoom": 1.5, "camX": -8, "camTiltY": -5, "camY": 1}], [50, "Smooth", {"camZoom": 1.62, "camX": 2, "camTiltY": 1, "camY": -0.5}], [100, null, {"camZoom": 1.46, "camX": 9, "camTiltY": 5, "camY": 1}]]}
295
+ {"id": "cm-punch-through", "cat": "Energy", "dur": 8.7, "sparse": true, "k": [[0, "Linear", {"camDolly": 98}], [71, "Custom", {"bezier": [0.8187, 0.1079, 0.9089, 0.302]}, {"camDolly": 234.7692}], [100, "AE In (arrive)", {"camDolly": 2600}]]}
296
+ {"id": "cm-orbit-pulse", "cat": "Loops", "dur": 7, "k": [[0, "Sine Smooth", {"camTiltY": -13, "camX": -5, "camY": 1, "camZoom": 1.06}], [50, "Sine Smooth", {"camTiltY": 13, "camX": 5, "camY": -1, "camZoom": 1.2}], [100, null, {"camTiltY": -13, "camX": -5, "camY": 1, "camZoom": 1.06}]]}
297
+ {"id": "cm-float", "cat": "Loops", "dur": 5.5, "k": [[0, "Sine Smooth", {"camY": 2.2, "camRoll": -1.6, "camZoom": 1.1}], [50, "Sine Smooth", {"camY": -2.2, "camRoll": 1.6, "camZoom": 1.17}], [100, null, {"camY": 2.2, "camRoll": -1.6, "camZoom": 1.1}]]}
298
+ {"id": "cm-figure-eight", "cat": "Loops", "dur": 6, "k": [[0, "Sine Smooth", {"camX": -11, "camY": 0, "camRoll": 0, "camZoom": 1.15}], [25, "Sine Smooth", {"camX": 0, "camY": 7, "camRoll": 2.5}], [50, "Sine Smooth", {"camX": 11, "camY": 0, "camRoll": 0}], [75, "Sine Smooth", {"camX": 0, "camY": -7, "camRoll": -2.5}], [100, null, {"camX": -11, "camY": 0, "camRoll": 0}]]}
299
+ {"id": "cm-rack-to-near", "cat": "Focus", "dur": 3.5, "k": [[0, "Smooth", {"camDofEnabled": 1, "camFocusDistance": 2800, "camFocusX": 68, "camFocusY": 48, "camAperture": 36, "camBlurLevel": 110, "camHighlightGain": 18}], [22, "Smooth", {}], [78, "Smooth", {"camFocusDistance": 1350, "camFocusX": 35, "camFocusY": 55}], [100, null, {}]]}
300
+ {"id": "cm-rack-to-far", "cat": "Focus", "dur": 3.5, "k": [[0, "Smooth", {"camDofEnabled": 1, "camFocusDistance": 1350, "camFocusX": 35, "camFocusY": 55, "camAperture": 36, "camBlurLevel": 110, "camHighlightGain": 18}], [22, "Smooth", {}], [78, "Smooth", {"camFocusDistance": 2800, "camFocusX": 68, "camFocusY": 48}], [100, null, {}]]}
301
+ {"id": "cm-focus-chase", "cat": "Focus", "dur": 6, "k": [[0, "Smooth", {"camDofEnabled": 1, "camFocusDistance": 1450, "camFocusX": 28, "camFocusY": 55, "camAperture": 30, "camBlurLevel": 95, "camHighlightGain": 12}], [45, "Smooth", {"camFocusDistance": 2600, "camFocusX": 72, "camFocusY": 42}], [100, null, {"camFocusDistance": 1750, "camFocusX": 48, "camFocusY": 50}]]}
302
+ {"id": "cm-macro-bloom", "cat": "Focus", "dur": 4.5, "k": [[0, "Smooth", {"camZoom": 1.05, "camDolly": 0, "camDofEnabled": 1, "camFocusDistance": 2500, "camFocusX": 50, "camFocusY": 50, "camAperture": 28, "camBlurLevel": 100, "camHighlightGain": 15}], [52, "Smooth", {"camZoom": 1.16, "camDolly": 100, "camFocusDistance": 1650, "camAperture": 46, "camBlurLevel": 125, "camHighlightGain": 32}], [100, null, {"camZoom": 1.23, "camDolly": 180, "camFocusDistance": 1150, "camAperture": 38, "camBlurLevel": 110, "camHighlightGain": 22}]]}
303
+ ```
304
+
305
+ - **Loops** (`cm-orbit-pulse`, `cm-float`, `cm-figure-eight`) end on their first snapshot, so they repeat seamlessly.
306
+ - **Focus:** `cm-rack-to-near`, `cm-rack-to-far` and `cm-focus-chase` animate depth of field only, so they compose with the camera's pose and a rig. `cm-macro-bloom` also pushes `camZoom` and `camDolly`, so it resets the pose and flips a rig to `"free"`.
307
+ - **`cm-punch-through`** (8.7 s, dolly only, `sparse`) writes one `camDolly` track and nothing else, so it composes with the pose the camera already holds: a linear creep from dolly 98 to 235 over the first 71 %, then one custom segment that detonates to 2600. On a 1080-tall project `perspectiveDistance ≈ 2166`, so the last instant flies **through** the comp plane and the frame blows out — that is the move, not a bug; park layers on negative `panZ` to stop the camera short of them.
251
308
 
252
309
  ### Camera Rig — a parametric camera move
253
310
 
254
- A camera item's `cameraMoveMode` picks one of four exclusive modes: `"free"` (position/rotation keyframes, the default), `"path"` (the hand-drawn motion path), `"rig"` (`item.cameraRig`) and `"smartZoom"` (the Movement tab **Zoom**: zoom ranges, from a screen recording's clicks or placed by hand — see Smart Zoom above). **A rig writes no path**, so switching modes never destroys a drawn curve.
311
+ A camera item's `cameraMoveMode` picks one of four exclusive modes: `"free"` (position/rotation keyframes), `"path"` (the drawn [camera path](#camera-motion-paths-camerapath--campatht)), `"rig"` (`item.cameraRig`) and `"smartZoom"` (Smart Zoom: zoom ranges, from a screen recording's clicks or placed by hand — see [Smart Zoom](#smart-zoom--the-cameras-zoom-mode)). An unknown value reads as `"free"`. An **absent** field also behaves as free, except that a live `cameraPath` still drives the camera. **A rig writes no path**, so switching modes never destroys a drawn curve.
255
312
 
256
313
  ```jsonc
257
314
  "cameraMoveMode": "rig",
@@ -269,47 +326,66 @@ A camera item's `cameraMoveMode` picks one of four exclusive modes: `"free"` (po
269
326
 
270
327
  Every type reads `radius`, `height`, `startAngle`, `tilt` and `roll`. On top of those: `orbit` adds `sweep`; `crane` adds `sweep` + `rise`; `spiral` adds `sweep` + `rise` + `turns`; `dolly`, `pushIn` and `truck` add `distance` (and read **no** `sweep`); `handheld` adds `amplitude` + `frequency`; `follow` adds `sweep` plus the `targets` list. The rest are stored and ignored.
271
328
 
272
- **`follow` — a drone that rides an object.** The rig's orbit maths are re-based onto a followed layer's position: the camera keeps looking at it while `radius`/`height`/`startAngle`/`sweep` place and circle it, and a **moving** target drags the whole move with it. `targets` is an array of layer **item ids** (any positioned layer — not audio/voice); the keyframable camera track **`camFollowTarget`** (1-based, default `1`) names which entry the camera looks at. A key from `1` to `2` pans the look-at from one object to the next smoothly with the key's easing — **fractional values blend the two targets' positions**, so the hand-off is a camera move, not a cut. With an empty `targets` list (or dangling ids) the rig degrades to a plain orbit of the comp centre. `camFollowTarget`, like `camPathT`, is a driver track: the rig reads it and never overwrites it. A target's position is resolved through the same PRS machinery that places its mesh, so a layer riding an **object Motion Path** (`motionPath` + `motionPathT`) or moved by **keyframe parenting** is followed where it actually renders — not just its `panX/panY` + position tracks.
273
-
274
- Parameter ranges (`CAMERA_RIG_PARAM_META` / `CAMERA_RIG_DEFAULTS`, `cameraRig.js`) — **values outside the range are clamped silently**, and the UI label differs from the JSON key for four of them:
329
+ **`follow` — a drone that rides an object.** The rig's orbit maths are re-based onto a followed layer's position: the camera keeps looking at it while `radius`/`height`/`startAngle`/`sweep` place and circle it, and a **moving** target drags the whole move with it. `targets` is an array of layer **item ids** (any positioned layer — not audio/voice); the keyframable camera track **`camFollowTarget`** (1-based, default `1`) names which entry the camera looks at. A key from `1` to `2` pans the look-at from one object to the next smoothly with the key's easing — **fractional values blend the two targets' positions**, so the hand-off is a camera move, not a cut. With an empty `targets` list (or dangling ids) the rig degrades to a plain orbit of the comp centre. `camFollowTarget`, like `camPathT`, is a driver track: the rig reads it and never overwrites it. A target is followed where it actually renders — through its position tracks, an object Motion Path (`motionPath` + `motionPathT`) or keyframe parenting.
275
330
 
276
- | Key | UI label | Range | Default |
277
- | --- | --- | --- | --- |
278
- | `radius` | Radius | `100`–`20000` px | `1800` |
279
- | `height` | Height | `−8000`–`8000` px | `400` (positive films from below the subject — a low hero angle; negative from above) |
280
- | `startAngle` | **Start** | `−360`–`360`° | `−40` |
281
- | `sweep` | Sweep | `−720`–`720`° | `90` |
282
- | `rise` | Rise | `−8000`–`8000` px | `0` |
283
- | `distance` | **Travel** | `−20000`–`20000` px | `1200` |
284
- | `turns` | Turns | `0.25`–`8` × | `1` |
285
- | `tilt` | Tilt | `−80`–`80`° | `−8` |
286
- | `roll` | Roll | `−180`–`180`° | `0` |
287
- | `amplitude` | **Amount** | `0`–`2000` px | `0` |
288
- | `frequency` | **Rate** | `0`–`40` × | `0` |
331
+ #### Rig parameters
289
332
 
290
- `seed` (default `7`) has no UI field and no range — it only re-rolls the `handheld` jitter. `targets` (default `[]`, max 50 ids) is the `follow` type's hand-off list; junk entries are dropped by the sanitizer.
333
+ **Values outside the range are clamped silently**:
291
334
 
292
- **Twelve built-in rigs** (`CAMERA_RIG_PRESETS`, `cameraRig.js`) — each is a **complete** `cameraRig`, applied wholesale (a partial one leaves the previous shot's radius behind): `heroOrbit`, `slowReveal`, `turntable` (orbit), `craneDown`, `riseReveal` (crane), `pushIn` (pushIn), `pullBack` (dolly), `truckLeft` (truck), `productSpiral` (spiral), `handheldDrift` (handheld), `droneFollow`, `chaseCam` (follow). Read the exact numbers from that file rather than guessing them. Follow presets carry **no `targets`** — which layers to ride is project wiring, and the panel preserves the current list when any preset is applied; emitting a preset over an existing rig should do the same.
335
+ | Key | Range | Default |
336
+ | --- | --- | --- |
337
+ | `radius` | `100`–`20000` px | `1800` |
338
+ | `height` | `−8000`–`8000` px | `400` (positive films from below the subject — a low hero angle; negative from above) |
339
+ | `startAngle` | `−360`–`360`° | `−40` (`0` = straight ahead) |
340
+ | `sweep` | `−720`–`720`° | `90` |
341
+ | `rise` | `−8000`–`8000` px | `0` |
342
+ | `distance` | `−20000`–`20000` px | `1200` |
343
+ | `turns` | `0.25`–`8` × | `1` |
344
+ | `tilt` | `−80`–`80`° | `−8` |
345
+ | `roll` | `−180`–`180`° | `0` |
346
+ | `amplitude` | `0`–`2000` px | `0` |
347
+ | `frequency` | `0`–`40` × | `0` |
293
348
 
294
- **Mode precedence — a rig or a live path BEATS the pose keyframes.** `getCameraPose` reads the tracks first, then `applyCameraRig` (mode `"rig"`) or `applyCameraPath` (mode `"path"` with a live path) overwrites `camX`, `camY`, `camDolly` — plus `camTiltX`/`camTiltY`/`camRoll` whenever an aim is computed. So on a rigged camera, `camX`/`camY`/`camDolly`/tilt/roll keyframes are stored, drawn in the timeline, and **render as nothing**. `camZoom`, the whole DOF family, `camPathT` and `camFollowTarget` are untouched by both and compose freely. Emitting a pose move therefore means emitting `"cameraMoveMode": "free"` (or omitting the field) with it — the editor enforces the same rule by flipping the mode to `"free"` when a motion preset that animates those props is applied, keeping `cameraRig` and `cameraPath` on the item so switching back restores the move.
349
+ `seed` (default `7`, an integer clamped to 0–100000) only re-rolls the `handheld` jitter. `targets` (default `[]`, max 50 ids) is the `follow` type's hand-off list; junk entries are dropped by the sanitizer. `aim` is `"center"` (default) or `"path"`.
295
350
 
296
- **`radius` sets the framing.** The project camera films the frame at its own size from the perspective distance `(height / 2) / tan(14°)` (`perspectiveDistance`, `CAMERA_FOV` 28°): about **2166 px for a 1080-tall frame** and **3850 px for a 1920-tall one (9:16)**. A rig closer than that crops in, farther pulls back — so the default `1800` already crops a 16:9 frame a little and a vertical 9:16 one hard; set `radius` near the frame's distance unless the push is the point.
351
+ **`radius` sets the framing.** The project camera films the frame at its own size from the perspective distance `(height / 2) / tan(14°)` (a 28° field of view): about **2166 px for a 1080-tall frame** and **3850 px for a 1920-tall one (9:16)**. A rig closer than that crops in, farther pulls back — so the default `1800` already crops a 16:9 frame a little and a vertical 9:16 one hard; set `radius` near the frame's distance unless the push is the point.
297
352
 
298
353
  **Rig parameters are NOT keyframable** — keying `radius` would rebuild the whole move every frame. **`camPathT` (Path Progress) is the track that carries the move**, and a rig with no `camPathT` keys sits frozen at its start pose. Always emit `camPathT` 0 → 100 alongside a `cameraRig`:
299
354
 
300
- ```jsonc
355
+ ```json
301
356
  "propertyKeyframes": { "camPathT": [
302
- { "position": 0, "value": 0, "easing": "Linear" },
303
- { "position": 100, "value": 100, "easing": "Linear" }
357
+ { "position": 0, "value": "0", "easing": "Linear" },
358
+ { "position": 100, "value": "100", "easing": "Linear" }
304
359
  ] }
305
360
  ```
306
361
 
307
- Keys use the same shape as every other track: `position` is 0–100 % of the camera clip, and `easing` is a named easing value. Progress tracks (`camPathT`, a layer's `motionPathT`) take `"Linear"` — constant speed along the move, which is what the editor itself writes; an eased progress (`"Smooth"`, `"AE Smooth"`) makes the camera crawl at both ends, so use one only when that slow start and landing is the intent. A key without a numeric `position` is dropped when the project loads, which leaves the rig frozen exactly as if no track had been sent.
308
-
309
- **`aim` picks what the camera faces.** `"center"` (the default, also when the key is absent) aims at the point the rig is built around — the comp centre, or the followed layer for `follow`. `"path"` faces the direction of travel (orient along path). It suits moves that travel toward or past the subject (`pushIn`, `dolly`, `crane`, `truck`); on an `orbit` or `spiral` it looks along the circle, roughly 90° away from the subject. A rig that does not move (zero `sweep`/`distance`, a `handheld` with `amplitude` 0) keeps the centre aim. `aim` is a choice about the shot's framing, not part of a preset: keep the current value when emitting a preset over an existing rig, as with `targets`.
362
+ `Linear` progress gives constant speed along the move, which is what the editor itself writes; an eased progress (`"Smooth"`, `"AE Smooth"`) makes the camera crawl at both ends, so use one only when that slow start and landing is the intent. A key without a numeric `position` is dropped when the project loads, which leaves the rig frozen exactly as if no track had been sent.
310
363
 
311
- **Flat layers have a front.** Text, shapes, pictures and video are flat cards: an orbit that swings more than about 75° off the front (`startAngle` + `sweep` past ±75°, with `startAngle: 0` = straight ahead) turns them edge-on, and past 90° the camera sees their backs and they read mirrored. Keep orbits of flat layers within about ±70° (`heroOrbit`, `slowReveal`); `turntable`'s full circle is for 3D models.
364
+ **`aim` picks what the camera faces.** `"center"` (the default, also when the key is absent) aims at the point the rig is built around — the comp centre, or the followed layer for `follow`. `"path"` faces the direction of travel. It suits moves that travel toward or past the subject (`pushIn`, `dolly`, `crane`, `truck`); on an `orbit` or `spiral` it looks along the circle, roughly 90° away from the subject. A rig that does not move (zero `sweep`/`distance`, a `handheld` with `amplitude` 0) keeps the centre aim. `aim` is a choice about the shot's framing, not part of a preset: keep the current value when emitting a preset over an existing rig, as with `targets`.
312
365
 
313
366
  **`tilt` moves the AIM POINT, not the camera's own rotation**, so the subject stays framed as the rig travels. With `"aim": "path"` the same lift tips the gaze above or below the direction of travel. `handheld` jitter is a pure function of `seed` and progress — never wall-clock — so preview and export match frame for frame.
314
367
 
315
- **Rendering.** Model layers render in the editor preview, the desktop Download, **`/render-onserver`** / **`/render-onserver-job`**, and **`/render-still`**. The fast `/render` does **not** support them (video/image only). Very heavy assets (≳900 k triangles) render slowly enough that an export can look hung — prefer decimated models.
368
+ **Flat layers have a front.** Text, shapes, pictures and video are flat cards: an orbit that swings more than about 75° off the front (`startAngle` + `sweep` past ±75°, with `startAngle: 0` = straight ahead) turns them edge-on, and past 90° the camera sees their backs and they read mirrored. Keep orbits of flat layers within about ±70°: `heroOrbit` (−40° → +50°) stays inside; `slowReveal` (−120° → +30°) and `chaseCam` (from 160°) start past it, and `turntable`'s full circle is for 3D models.
369
+
370
+ #### Built-in rigs
371
+
372
+ The editor's twelve rig presets. Fields not listed take the defaults in [Rig parameters](#rig-parameters), so a preset is applied **whole**: write every field, or a partial rig keeps the previous shot's radius. Follow presets carry **no `targets`** — which layers to ride is project wiring, and applying any preset keeps the current list (and `aim`); do the same when you emit one over an existing rig.
373
+
374
+ | Preset | `type` | `radius` | `height` | `startAngle` | Type-specific | `tilt` |
375
+ | --- | --- | --- | --- | --- | --- | --- |
376
+ | `heroOrbit` | orbit | 1800 | 400 | −40 | sweep 90 | −8 |
377
+ | `slowReveal` | orbit | 2400 | 220 | −120 | sweep 150 | −4 |
378
+ | `turntable` | orbit | 1500 | 300 | 0 | sweep 360 | −6 |
379
+ | `craneDown` | crane | 1700 | 1400 | −30 | sweep 55, rise −1100 | 14 |
380
+ | `riseReveal` | crane | 1900 | 150 | −60 | sweep 80, rise 1200 | −16 |
381
+ | `pushIn` | pushIn | 900 | 260 | 0 | distance 1800 | −3 |
382
+ | `pullBack` | dolly | 2600 | 380 | 12 | distance −1700 | −6 |
383
+ | `truckLeft` | truck | 1600 | 300 | 0 | distance 2200 | −4 |
384
+ | `productSpiral` | spiral | 2800 | 240 | −20 | sweep 300, turns 1, rise 800 | −16 |
385
+ | `handheldDrift` | handheld | 1500 | 320 | −8 | amplitude 90, frequency 1.5 | −5 |
386
+ | `droneFollow` | follow | 1600 | 420 | −35 | sweep 110 | −7 |
387
+ | `chaseCam` | follow | 950 | 170 | 160 | sweep 35 | −4 |
388
+
389
+ #### Mode precedence
390
+
391
+ **A rig or a live path BEATS the pose keyframes.** The camera reads its tracks first; then a rig (mode `"rig"`) or a live path (mode `"path"` or absent) overwrites `camX`, `camY`, `camDolly` — plus `camTiltX`/`camTiltY`/`camRoll` whenever an aim is computed. So on a rigged camera, `camX`/`camY`/`camDolly`/tilt/roll keyframes are stored, drawn in the timeline, and **render as nothing**. `camZoom`, the whole depth-of-field family, `camPathT` and `camFollowTarget` are untouched by both and compose freely. To emit a pose move, emit `"cameraMoveMode": "free"` explicitly with it (omitting the field leaves a live `cameraPath` in control). The editor enforces the same rule by flipping the mode to `"free"` when a camera preset that animates those props is applied, keeping `cameraRig` and `cameraPath` on the item so switching back restores the move.
@@ -30,7 +30,7 @@ and check every version against them.
30
30
  - Shapes and pictures enter in 0.4–0.8 s; exits are faster than entrances.
31
31
  - A text animation takes at least 0.8–1 s over the whole line, first unit in to last unit settled, unless the video
32
32
  needs snap (a beat hit, a glitch flick): faster text flashes past before it is read. The arithmetic per kind of
33
- animation: video-projects.md, Text animation pace.
33
+ animation: [Text animation pace](#text-animation-pace).
34
34
 
35
35
  ## Easing by intent
36
36
 
@@ -38,23 +38,51 @@ and check every version against them.
38
38
  Camera and big moves: `AE Smooth`.
39
39
  - Scene changes on the beat, a logo hit, a screen swipe: `Punch` (slow wind-up, hard hit, long tail).
40
40
  - A layer that passes through (in, glide, out): `Smooth Glide` with the `glide` expression — the Glide keyframe
41
- presets build it (video-keyframes.md).
41
+ presets build it ([video-keyframes.md](video-keyframes.md)).
42
42
  - Playful arrivals: `Back Out` or `Overshoot`; a real spring: the `spring` expression (Snappy for buttons, icons and
43
- pop-ins, Gentle for mascots, big cards and the camera). Exits: `Ease In` or `AE Out (leave)`.
43
+ pop-ins, Gentle for mascots, big cards and the camera). Exits: `Ease In` or `AE Out (leave)`; the Kinetic recipes'
44
+ `Anticipate` exits (wind back, then snap away) are the exception and stay as tuned.
44
45
  - `Linear` only for constant motion: tickers, rotation, progress along a path.
45
46
 
46
47
  ## Text animation
47
48
 
48
49
  - Animate words or letters, never a whole line at once — the text presets do this.
49
- - One family per video. Calm presets for lines and captions: Glide Text (`glide-text`), Classic Fade Up
50
- (`classic-fade-up`), Classic Rise (Mask) (`classic-rise-mask`); Focus Type (`focus-type`) for a title.
51
- - Punchy presets that tilt or overshoot — Swing Pop (`swing-pop`), Whip (`whip`), Letter Vortex (`letter-vortex`) —
52
- only on 1–3 words when the video wants energy. On a longer caption they read cheap.
50
+ - One family per video. Calm presets for lines and captions: Classic Fade Up (`classic-fade-up`), Classic Rise (Mask)
51
+ (`classic-rise-mask`), Glide Text (`glide-text`); Focus Type (`focus-type`) for a title. All are in
52
+ [video-text-presets.md](video-text-presets.md).
53
+ - Punchy presets that tilt or overshoot ([video-text-kinetic.md](video-text-kinetic.md): Swing Pop (`swing-pop`), Whip (`whip`),
54
+ Letter Vortex (`letter-vortex`)…) only on short titles, hooks and calls to action — up to about 5 words, or 2–4 short
55
+ stacked lines — and only when the video wants energy. On a longer line or a caption under speech they read cheap.
53
56
  - Glide Continuous (`glide-continuous`) and Glide Soft Stop (`glide-soft-stop`) never let the text rest: use them
54
57
  only when the user asks for drifting text.
55
58
  - On a coloured card, pick a preset without a colour band (Classic Rise (Mask), Classic Fade Up): a band's colour can
56
59
  clash with the card.
57
60
 
61
+ ## Text animation pace
62
+
63
+ By default, text that animates in, animates out or gets a one-time pass (a sweep, a highlight, a typed line) takes
64
+ **at least 0.8–1 s**, counted from the moment its first unit starts to the moment its last unit settles. Faster than
65
+ that, the words flash past before anyone can read them. In a Video Editor project the rule holds however the motion is
66
+ built:
67
+
68
+ - **Advanced Animations on Time** (`textAnimators[].range.drive: "time"`): the span is
69
+ `(units − 1) × stagger + duration`. For example, 4 words with `stagger` 0.06 and `duration` 0.8 take 0.98 s. For
70
+ moves (rise, slide, scale, rotate, blur), reach the span with `duration`, because a long `stagger` makes a long line
71
+ crawl. A typewriter (`duration` 0) gets its pace from `stagger`.
72
+ - **Keyframes** (`txa:` range tracks such as Start or Offset, or a text layer's own position, scale and opacity
73
+ tracks): the first and last key of one move sit at least 0.8 s apart. Key positions are % of the layer, so a move
74
+ from `p1` to `p2` lasts `(p2 − p1) / 100 × layer seconds`. On a layer with a speed curve, a `txa:` key's position is
75
+ a % of the clip's source length (`trimEnd − trimStart`) instead, so time the move on that clock
76
+ ([video-text.md](video-text.md)).
77
+ - **Layer entrances and exits** on a text layer: `transitionInDuration` / `transitionOutDuration` of 0.8 or more
78
+ (the most they take is 3 s).
79
+ - **Recipes** from [video-text-kinetic.md](video-text-kinetic.md) and [video-text-presets.md](video-text-presets.md) keep their tuned timing.
80
+ On very short text (one or two words), where the span works out under 0.8 s, raise `duration` until it reaches 0.8 s.
81
+
82
+ Go faster only when the edit really needs it: the user asks for snappy or fast text, a word hits on a beat or a fast
83
+ cut, a glitch or scramble flicks, the layer is too short to fit a 0.8 s move, or the animator uses `drive: "voice"`
84
+ (the speech sets the pace there).
85
+
58
86
  ## Transitions and camera
59
87
 
60
88
  - Vary two or three kinds of transition across a video: one kind everywhere looks like a template, a new one at
@@ -70,6 +98,10 @@ and check every version against them.
70
98
 
71
99
  - A soft whoosh on each transition and on the first headline is a good base. `data-1` or `selection-data-click` for
72
100
  UI-like pops, `cinematic-transition-13` for one big reveal.
101
+ - Take sound effects from the user's Sound Library first
102
+ (media.md (GreenLight Dash only)). The ids above are GreenLight Motion library sounds, not
103
+ Sound Library files: `GET /api/motion-sounds/v1/<id>.wav` serves one (e.g. `/api/motion-sounds/v1/data-1.wav`). Save
104
+ it into the board folder and use the board url as an `audio` clip's `url`.
73
105
 
74
106
  ## What to check in every version
75
107