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.
@@ -6,33 +6,19 @@
6
6
 
7
7
  # GreenLight Dash — Video projects and timeline basics
8
8
 
9
- > Part of the greenlight-dash skill. Read this shared contract before editing a timeline, then only the relevant topic below. Do not load every video reference.
10
-
11
- ## Task routing
12
-
13
- | Task | Reference |
14
- | --- | --- |
15
- | Full export, render jobs, still frames, AI Fill | video-rendering.md (GreenLight Dash only) |
16
- | AI Reframe / Beats / Sound Design, saved analysis, speed curves and smoothing | video-analysis.md (GreenLight Dash only) |
17
- | Keyframe tracks, easing, custom expressions | [video-keyframes.md](video-keyframes.md) |
18
- | Object motion paths, point reduction, text on path, motion follow | [video-paths.md](video-paths.md) |
19
- | Cross transitions, layer entrances/exits, Custom lanes | [video-transitions.md](video-transitions.md) |
20
- | Text, captions, reveal presets, background-bar animations | [video-text.md](video-text.md) |
21
- | Background fills and GLSL shaders | [video-backgrounds.md](video-backgrounds.md) |
22
- | Shape paths, strokes and vector provenance | [video-shapes.md](video-shapes.md) |
23
- | Grid Builder layouts | [video-grid.md](video-grid.md) |
24
- | Using MotionFast templates and device mockup clips | video-motion.md (GreenLight Dash only) |
25
- | HTML pages as timeline layers (html clips) and their bakes | video-html-clips.md (GreenLight Dash only) |
26
- | Overlay Effects and Layer Effects, including Running Glow and Depth | [video-effects.md](video-effects.md) |
27
- | 3D models, Scene Rig, lighting and shadow plates | [video-3d.md](video-3d.md) |
28
- | Mechanisms and teardowns: a working machine or an exploded device as a 3D layer | video-mechanisms.md (GreenLight Dash only) |
29
- | Camera layer, Camera Rig, camera paths and presets | [video-camera.md](video-camera.md) |
30
- | Particles, packing, proximity, fluid and smoke | [video-particles.md](video-particles.md) |
31
- | Writing motion tracks, roto masks or solved cameras | tracking-data.md (GreenLight Dash only) |
32
- | You are making one version of a project for Variants (your copy, the scope, `ready`) | video-variants.md (GreenLight Dash only) |
33
- | AI Viral Edits (kinetic words around a layer's subject) and sequences with an Essential panel (`clip.essential`) | the `viral-edits` workflow on the `greenlight` MCP server (SKILL.md (GreenLight Dash only)); `essential` below |
34
-
35
- In this file: Editing and verification · Text animation pace · Video Projects (list, get, card, create, update, collaborative boards, refresh revision, activate, delete, build from clips) · Editor `data` shape · Sequence clips · Video presets (and project export / import bundles, GreenLight Motion pages) · Interface scenes.
9
+ > Part of the greenlight-dash skill. Read this shared contract before editing a timeline, then only the relevant topic in README.md (GreenLight Dash only). Do not load every video reference.
10
+
11
+ ## Contents
12
+
13
+ - [Editing and verification](#editing-and-verification)
14
+ - [Text animation pace](#text-animation-pace): a pointer to design.md
15
+ - [Video Projects (per board)](#video-projects-per-board): [list](#list-video-projects) · [get](#get-video-project) · [card](#project-card-what-a-canvas-card-shows) · [create](#create-video-project) · [update](#update-video-project) · [collaborative boards](#on-a-collaborative-board) · [refresh revision](#watch-for-editor-project-changes) · [activate](#activate-video-project) · [delete](#delete-video-project) · [build from clips](#build-a-project-from-a-flat-list-of-clips-agent-friendly)
16
+ - [Editor `data` shape](#editor-data-shape-for-postput-video-projects): [channel order](#channel-order-is-stacking-order) · [one live item per channel](#one-live-item-per-channel) · [sorted unsnapped items](#keep-unsnapped-items-sorted-by-freestart) · [preview source](#preview-source) · [screen recordings](#screen-recordings) · [scale](#layer-scale-is-three-fields) · [grade (`filter*`)](#layer-grade--the-static-filter-fields) · [blend mode](#blend-mode) · [mirror](#mirror) · [layer titles](#layer-titles) · [real-size media](#videos-and-photos-draw-at-their-real-size) · [Fit screen](#fit-screen) · [SVG](#svg-clips-are-vector-layers) · [GIF / WebP / APNG](#animated-image-clips-gif-animated-webp-apng) · [timeline markers](#timeline-markers) · [layer markers](#layer-markers) · [colour tokens](#design-tokens-project-colour-palette) · [photo slots](#photo-slots-pictures-people-may-replace) · [background](#project-background) · [motion blur](#motion-blur-render-only) · [renderer](#renderer) · [sound and fades](#sound--audio-fades)
17
+ - [Sequence clips](#sequence-clips-mediatype-sequence)
18
+ - [Video presets](#video-presets-save--load-reusable-sequences)
19
+ - [Project export and import (.zip bundles)](#project-export-and-import-zip-bundles)
20
+ - [GreenLight Motion pages (Export for agents)](#greenlight-motion-pages-export-for-agents)
21
+ - [Interface scenes (the UI kit)](#interface-scenes-built-in-animated-scenes--the-ui-kit)
36
22
 
37
23
  Use `GET /api/video-editor/capabilities` for the installed build's effect and transition ids, fields, ranges and render support. Read the relevant subtree instead of copying a catalog into the prompt. For custom MotionFast authoring, use the authoring skill (GreenLight Dash only).
38
24
 
@@ -41,19 +27,13 @@ Use `GET /api/video-editor/capabilities` for the installed build's effect and tr
41
27
  1. GET the project and retain its top-level `version` separately from `data.version` (the editor schema version).
42
28
  2. Modify only the requested fields, preserving unrelated clips, channels, items, assets and metadata. Keep every simultaneously visible layer on its own channel.
43
29
  3. PUT the full updated `data` with `expected_version` from that GET. Resolve a 409 by fetching and reapplying the intended edit; never force a stale snapshot through.
44
- 4. GET again to verify the saved structure. For visible changes, use a composited still or a short full render from video-rendering.md (GreenLight Dash only) to check the result: a successful save alone does not prove an effect or track renders.
30
+ The user may be editing the same project while you work. The open editor merges your write into their unsaved timeline layer by layer, so their work and yours both stay. Only when you both changed the same layer (or the same project setting) does it ask the user which to keep. So change only the layers your task needs: a write that rewrites every layer collides with whatever the user is touching.
31
+ 4. GET again to verify the saved structure. For visible changes, check a composited still (`/render-still` (GreenLight Dash only)): a successful save alone does not prove an effect or track renders.
45
32
  5. Keep all media used by the project, including nested sequences and effect assets, even when it has no canvas card. Follow media cleanup (GreenLight Dash only).
46
33
 
47
34
  ## Text animation pace
48
35
 
49
- By default, text that animates in, animates out or gets a one-time pass (a sweep, a highlight, a typed line) takes **at least 0.8–1 s**, counted from the moment its first unit starts to the moment its last unit settles. Faster than that, the words flash past before anyone can read them. The rule holds however the motion is built:
50
-
51
- - **Advanced Animations on Time** (`textAnimators[].range.drive: "time"`): the span is `(units − 1) × stagger + duration`. For example, 4 words with `stagger` 0.06 and `duration` 0.8 take 0.98 s. For moves (rise, slide, scale, rotate, blur), reach the span with `duration`, because a long `stagger` makes a long line crawl. A typewriter (`duration` 0) gets its pace from `stagger`.
52
- - **Keyframes** (`txa:` range tracks such as Start or Offset, or a text layer's own position, scale and opacity 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 from `p1` to `p2` lasts `(p2 − p1) / 100 × layer seconds`.
53
- - **Layer entrances and exits** on a text layer: `transitionInDuration` / `transitionOutDuration` of 0.8 or more.
54
- - **Recipes** from [video-text-kinetic.md](video-text-kinetic.md) and [video-text-presets.md](video-text-presets.md) keep their tuned timing. 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.
55
-
56
- 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 cut, a glitch or scramble flicks, the layer is too short to fit a 0.8 s move, or the animator uses `drive: "voice"` (the speech sets the pace there).
36
+ Text that animates in, out or gets a one-time pass takes at least 0.8–1 s by default. The rule, the arithmetic per kind of animation and the exceptions are in [video-design.md](video-design.md#text-animation-pace).
57
37
 
58
38
  ## Video Projects (per board)
59
39
 
@@ -132,7 +112,7 @@ on top of it. What happens on screen depends on the user:
132
112
  stays on the server until the user resolves it, and may deliberately be
133
113
  replaced by theirs. The editor cannot autosave over you meanwhile.
134
114
  - `GET` returns the last **saved** copy, which can be behind what is on the
135
- user's screen. With GLEA attached the editor flushes every ~5s, so in that
115
+ user's screen. With GLAI open in the editor it flushes every ~5s, so in that
136
116
  workflow you are reading current state. Long agent runs are the risk: if the user keeps editing
137
117
  while you work, re-`GET` before the final `PUT` rather than writing a payload
138
118
  built from a read you took minutes ago.
@@ -163,9 +143,10 @@ it: successful project mutation endpoints bump it automatically.
163
143
 
164
144
  ```
165
145
  POST /api/moodboards/{board_id}/video-projects/{project_id}/activate
166
- { "project_id?": "..." }
167
146
  ```
168
147
 
148
+ No request body.
149
+
169
150
  Sets the board's `active_video_project_id`.
170
151
 
171
152
  ### Delete video project
@@ -208,50 +189,113 @@ Use this to assemble the workflow's per-step videos into a project in one call w
208
189
 
209
190
  ### Editor `data` shape (for `POST/PUT video-projects`)
210
191
 
211
- Video-editor `mediaType` values include `video`, `image`, `audio`, `text`, `shape`, `adjustment`, `sequence` (compound clip — see [Sequence clips](#sequence-clips-mediatype-sequence)), `grid` (Grid Builder — a container that lays its members out by a rule; see [video-grid.md](video-grid.md)), `motion` (native MotionFast animated template — see video-motion.md (GreenLight Dash only)), `model3d` (a live 3D model rendered in its own pass — see [video-3d.md](video-3d.md); its clip `url` must be a `model-preset:` or `model-primitive:` sentinel or a real asset URL, **never empty**), `particles` (a seeded particle simulation in a Solver Box, filmed by the project camera — see [video-particles.md](video-particles.md); the whole system lives on the ITEM as `particleSystem`), `camera` (virtual camera over the whole stack — see [video-camera.md](video-camera.md)), and `null` (a transform with no picture, never drawn in any render: the parent other layers hang off — see [Layer parenting and null layers](video-keyframes.md#layer-parenting-and-null-layers-keyframeparenting); clip `url` is `""`; item `nullColor` (default `#ff3b30`) and `nullSize` (marker diameter in composition px, 2–2000, default 24) style only the editor's preview marker). The fast `/render` only accepts `video` and `image`; **`/render-onserver`** (full headless render) bakes in the richer timeline types — `text`, `shape`, `sequence`, `audio`, `motion`, `model3d`, `particles`, keyframes, filters — exactly like the desktop editor. A **device mockup** is not its own `mediaType` — it is a rendered `video` clip that optionally carries a `mockupConfig` for re-editing (see video-motion.md (GreenLight Dash only)).
192
+ The video-editor `mediaType` values are:
193
+
194
+ - `video`, `image`, `audio`, `text`, `shape`, `adjustment`;
195
+ - `sequence`: a compound clip, see [Sequence clips](#sequence-clips-mediatype-sequence);
196
+ - `grid`: Grid Builder, a container that lays its members out by a rule, see [video-grid.md](video-grid.md);
197
+ - `motion`: a native MotionFast animated template, see motion.md (GreenLight Dash only);
198
+ - `model3d`: a live 3D model rendered in its own pass, see [video-3d.md](video-3d.md). Its clip `url` must be a `model-preset:` or `model-primitive:` sentinel or a real asset URL, **never empty**;
199
+ - `rig`: the Scene Rig (one set for every 3D layer), see [video-3d.md](video-3d.md#scene-rig--one-set-for-every-3d-layer);
200
+ - `particles`: a seeded particle simulation in a Solver Box, filmed by the project camera, see [video-particles.md](video-particles.md). The whole system lives on the ITEM as `particleSystem`;
201
+ - `camera`: a virtual camera over the whole stack, see [video-camera.md](video-camera.md);
202
+ - `html`: an HTML page as a layer, see html-clips.md (GreenLight Dash only);
203
+ - `after-effects`: a render of one After Effects composition, see ae-clips.md (GreenLight Dash only);
204
+ - `null`: a transform with no picture, never drawn in any render; the parent other layers hang off, see [Layer parenting and null layers](video-keyframes.md#layer-parenting-and-null-layers-keyframeparenting). Its clip `url` is `""`; the item's `nullColor` (default `#ff3b30`) and `nullSize` (marker diameter in composition px, 2–2000, default 24) style only the editor's preview marker.
212
205
 
213
- If you need to build the editor state by hand instead of using `/from-clips`, the canonical schema is the `sanitizeClip` / `sanitizeTimelineItem` / `buildPersistedState` functions in `frontend/src/components/videoEditor/VideoEditor.jsx`. Minimum viable shape:
206
+ Any other value loads as `video`, and a `video` clip whose url is an audio file loads as `audio`. A clip of a type that needs a file (`video`, `image`, `audio`, `model3d`, `html`, `after-effects`) is dropped on load when its `url` is empty. A **device mockup** is not its own `mediaType`: it is a rendered `video` clip that may carry a `mockupConfig` for re-editing (see motion.md (GreenLight Dash only)). Which render path draws which type: rendering.md (GreenLight Dash only).
207
+
208
+ If you need to build the editor state by hand instead of using `/from-clips`, start from this minimum shape; the editor fills in the rest when it loads:
214
209
 
215
210
  ```json
216
211
  {
217
- "version": 3,
212
+ "version": 2,
218
213
  "videoSize": { "w": 1920, "h": 1080 },
219
214
  "motionBlurEnabled": false,
220
- "clips": [{ "id", "source": "REMOTE", "mediaType": "video", "url", "name", "duration", "proxyUrl?", "thumbnailUrl?", "previewOriginal?" }],
221
- "channels": [{ "id", "name": "Channel 1", "visible": true, "items": [{ "id", "clipId", "transition", "transitionDuration", "transitionEasing", "transitionOrder?", "trimStart", "trimEnd", /* + the other sanitized fields */ }] }],
222
- "activeChannelId": "<id of the channel>",
215
+ "clips": [
216
+ { "id": "c1", "source": "REMOTE", "mediaType": "video", "url": "/media/admin/{board_id}/a.mp4", "name": "A", "duration": 5 }
217
+ ],
218
+ "channels": [
219
+ { "id": "ch1", "name": "Channel 1", "visible": true, "items": [
220
+ { "id": "i1", "clipId": "c1", "trimStart": 0, "trimEnd": 5,
221
+ "transition": "None", "transitionDuration": 1, "transitionEasing": "Smooth" }
222
+ ] }
223
+ ],
224
+ "activeChannelId": "ch1",
223
225
  "selectedTimelineId": null,
224
226
  "currentTime": 0
225
227
  }
226
228
  ```
227
229
 
228
- **Preview source.** `"previewOriginal": true` on a clip makes the editor preview play the full-res original instead of its `proxyUrl` — sharper when scale/crop keyframes zoom in, at a heavier decode cost. Preview-only: every render path already uses the original.
230
+ `data.version` is the editor's schema version (2); keep what the GET returned. A clip may also carry `proxyUrl`, `thumbnailUrl` and `previewOriginal`, and an item `transitionOrder` ([video-transitions.md](video-transitions.md#transition-fields)); every field you leave out takes its default.
231
+
232
+ #### Channel order is stacking order
233
+
234
+ Channels composite in array order: the **last** channel in `channels` is drawn on top. Put the footage channel first and the titles, captions and overlays after it; to put a new layer above everything, append its channel at the end.
235
+
236
+ #### One live item per channel
237
+
238
+ Treat every channel as one timeline track, not as a container of simultaneously composited layers. At any timeline time, a channel may have at most one active item. If two items' visible windows overlap, only one is guaranteed to render (the later-starting item commonly wins) and the save API does not reject the project. For an unsnapped item, its ordinary visible window is `[freeStart, freeStart + (trimEnd - trimStart))`; validate these windows before `PUT`. Put every simultaneously visible image, shape, text block, motion layer, or other visual on a separate channel. This rule also applies inside sequence timelines. Reuse one channel for consecutive, non-overlapping captions or shots only.
239
+
240
+ #### Keep unsnapped items sorted by `freeStart`
241
+
242
+ On an `unsnapped` channel the editor treats the item BEFORE a clip in the `items` array as "the clip before it": cross transitions and the layout's overlap read array neighbours, not time neighbours. The editor re-sorts on every insert, move and paste; an API write does not. After adding or moving items, sort each unsnapped channel's `items` by `freeStart` before `PUT`. An out-of-order array looks fine until the user adds a cross transition: the clip is then snapped against the wrong neighbour and jumps elsewhere on the timeline (e.g. past the end), so it appears to vanish.
243
+
244
+ #### Preview source
245
+
246
+ `"previewOriginal": true` on a clip makes the editor preview play the full-res original instead of its `proxyUrl` — sharper when scale/crop keyframes zoom in, at a heavier decode cost. Preview-only: every render path already uses the original.
247
+
248
+ #### Screen recordings
249
+
250
+ A recording clip carries `recordingMeta` (its clicks and a light cursor path, in seconds of the file) and may carry `recordingTrackUrl` (the full-rate `.glcursor` beside it) and `recordingMeta.cursorInVideo: false` (no cursor in its picture: the editor draws one). Its camera is a Smart Zoom item, and its item's `recordingCursor` sets the drawn cursor's look. Both are in [camera.md](video-camera.md) ("Smart Zoom" and "Drawn cursor").
251
+
252
+ #### Layer scale is three fields
253
+
254
+ `zoom` is the uniform scale (0.1–20, default 1), and `scaleX` / `scaleY` (same range, both default 1) are per-axis multipliers on top of it — so a layer's drawn width is `zoom × scaleX` and its height `zoom × scaleY`. Use `zoom` alone for an ordinary resize; reach for the other two only to stretch or squash one axis (a wide title bar, a squash-and-stretch bounce). All three inherit through layer parenting. Every one of them also has a keyframe track (`scale`, `scaleX`, `scaleY` — see [video-keyframes.md](video-keyframes.md)), which multiplies the base field rather than replacing it. A missing `scaleX`/`scaleY` is 1.
255
+
256
+ #### Layer grade — the static `filter*` fields
257
+
258
+ Every visual item carries a CSS-style grade applied to the layer before it composites: `filterBlur` (px, 0–10, default 0), `filterBrightness` / `filterContrast` / `filterSaturation` (%, 0–200, default 100), `filterHueRotate` (deg, 0–360, default 0), `filterGrayscale` / `filterSepia` / `filterInvert` (%, 0–100, default 0; Invert at 100 is a full negative). These are the layer's own base values; the matching keyframe tracks (`blur`, `brightness`, `contrast`, `hue-rotate`, `saturate`, `grayscale`, `sepia`, `invert`) **override** the static field while the track exists rather than multiplying it, so key one or set the other, not both. They are a cheap per-layer grade and independent of the Overlay Effects chain — reach for `effects[]` when you need a real grade (curves, LUT, colour balance).
229
259
 
230
- **Screen recordings.** A recording clip carries `recordingMeta` (its clicks and a light cursor path, in seconds of the file) and may carry `recordingTrackUrl` (the full-rate `.glcursor` beside it) and `recordingMeta.cursorInVideo: false` (no cursor in its picture: the editor draws one). Its camera is a Smart Zoom item, and its item's `recordingCursor` sets the drawn cursor's look. Both are in [video-camera.md](video-camera.md) ("Smart Zoom" and "Drawn cursor").
260
+ #### Blend mode
231
261
 
232
- **Layer scale is three fields.** `zoom` is the uniform scale (0.1–20, default 1), and `scaleX` / `scaleY` (same range, both default 1) are per-axis multipliers on top of it — so a layer's drawn width is `zoom × scaleX` and its height `zoom × scaleY`. Use `zoom` alone for an ordinary resize; reach for the other two only to stretch or squash one axis (a wide title bar, a squash-and-stretch bounce). All three are the panel's Transform → Scale row (X / Y / XY) and all three inherit through layer parenting. Every one of them also has a keyframe track (`scale`, `scaleX`, `scaleY` — see [video-keyframes.md](video-keyframes.md)), which multiplies the base field rather than replacing it. Projects written before these existed load unchanged: a missing `scaleX`/`scaleY` is 1.
262
+ `blendMode` is any Photoshop mode, by camelCase id: `"normal"` (default), `"dissolve"`; `"darken"`, `"multiply"`, `"colorBurn"`, `"linearBurn"`, `"darkerColor"`; `"lighten"`, `"screen"`, `"colorDodge"`, `"add"` (Linear Dodge), `"lighterColor"`; `"overlay"`, `"softLight"`, `"hardLight"`, `"vividLight"`, `"linearLight"`, `"pinLight"`, `"hardMix"`; `"difference"`, `"exclusion"`, `"subtract"`, `"divide"`; `"hue"`, `"saturation"`, `"color"`, `"luminosity"`. `opacity` works with all of them. Two implementations, same result in preview and every render path: `multiply` / `screen` / `add` / `lighten` / `darken` are GPU hardware blends with no extra pass (the source is faded toward the mode's neutral colour first; exact for multiply/screen/add, monotonic approximations for lighten/darken), and every other mode reads the layers below in a shader, which routes the layer through the compositor's isolate branch — one extra full-frame pass per such layer, so prefer the hardware five where they give the look you want on a heavy project. A shader-mode layer composites flat: inside a real-3D frame it does not depth-test against other layers for that frame. Text layers ignore the hardware five (they composite Normal) but take every shader mode. Every mode works on a layer that also carries `layerEffects`.
233
263
 
234
- **Layer grade — the static `filter*` fields.** Every visual item carries a CSS-style grade applied to the layer before it composites: `filterBlur` (px, 0–10, default 0), `filterBrightness` / `filterContrast` / `filterSaturation` (%, 0–200, default 100), `filterHueRotate` (deg, 0–360, default 0), `filterGrayscale` / `filterSepia` / `filterInvert` (%, 0–100, default 0; Invert at 100 is a full negative). These are the layer's own base values; the matching keyframe tracks (`blur`, `brightness`, `contrast`, `hue-rotate`, `saturate`, `grayscale`, `sepia`, `invert`) **override** the static field while the track exists rather than multiplying it, so key one or set the other, not both. They are a cheap per-layer grade and independent of the Overlay Effects chain — reach for `effects[]` when you need a real grade (curves, LUT, colour balance).
264
+ #### Mirror
235
265
 
236
- **Blend mode.** `blendMode` is any Photoshop mode, by camelCase id: `"normal"` (default), `"dissolve"`; `"darken"`, `"multiply"`, `"colorBurn"`, `"linearBurn"`, `"darkerColor"`; `"lighten"`, `"screen"`, `"colorDodge"`, `"add"` (Linear Dodge), `"lighterColor"`; `"overlay"`, `"softLight"`, `"hardLight"`, `"vividLight"`, `"linearLight"`, `"pinLight"`, `"hardMix"`; `"difference"`, `"exclusion"`, `"subtract"`, `"divide"`; `"hue"`, `"saturation"`, `"color"`, `"luminosity"`. `opacity` works with all of them. Two implementations, same result in preview and every render path: `multiply` / `screen` / `add` / `lighten` / `darken` are GPU hardware blends with no extra pass (the source is faded toward the mode's neutral colour first; exact for multiply/screen/add, monotonic approximations for lighten/darken), and every other mode reads the layers below in a shader, which routes the layer through the compositor's isolate branch — one extra full-frame pass per such layer, so prefer the hardware five where they give the look you want on a heavy project. A shader-mode layer composites flat: inside a real-3D frame it does not depth-test against other layers for that frame. Text layers ignore the hardware five (they composite Normal) but take every shader mode. The old caveat — `multiply` / `darken` painting a layer's own footprint black when it also carried a `layerEffects` entry — is fixed: the isolate is now drawn with the hardware blend suspended and the mode is applied when the plate meets the stack.
266
+ `mirrorH` / `mirrorV` (booleans, default false) flip the layer's picture on its own axes, after scale and before rotation. Not the same as a negative scale — they never change the layer box, so framing, masks and child layers are unaffected.
237
267
 
238
- **Mirror.** `mirrorH` / `mirrorV` (booleans, default false) flip the layer's picture on its own axes, after scale and before rotation. Not the same as a negative scale — they never change the layer box, so framing, masks and child layers are unaffected.
268
+ #### Layer titles
239
269
 
240
- **Layer titles.** Optional `"customName"` on any timeline item — the display title drawn on the clip in the timeline (and in the inspector's selected-layer header) in place of the clip/media name. Trimmed string, max 120 chars; empty or missing falls back to the default (the clip's `name`, or the text content for text layers). Set it whenever you build or edit a timeline so layers are self-describing — `"Intro logo"`, `"CTA caption"`, `"BG loop"` — instead of raw filenames; users can also rename in-app via the pencil beside the selected-layer title in the inspector panel. Display-only: no render path reads it.
270
+ Optional `"customName"` on any timeline item — the display title drawn on the clip in the timeline in place of the clip/media name. Trimmed string, max 120 chars; empty or missing falls back to the default (the clip's `name`, or the text content for text layers). Set it whenever you build or edit a timeline so layers are self-describing — `"Intro logo"`, `"CTA caption"`, `"BG loop"` — instead of raw filenames; a rename in the editor writes the same field. Display-only: no render path reads it.
241
271
 
242
- **Videos and photos draw at their real size.** A `video` clip (like a bitmap `image`) keeps its own aspect ratio and never gets bars: larger than the composition, it is shrunk to fit; smaller than the composition on both axes, it keeps its pixel size. A 1280×720 clip in a 1920×1080 project covers 1280×720 at `zoom: 1` (set `zoom: 1.5` to fill the frame), and a 1080×1920 clip in a 16:9 project is a centred portrait picture. Whatever the picture does not cover shows the layers below, or the project background.
272
+ #### Videos and photos draw at their real size
243
273
 
244
- **Fit screen.** To make a video or image layer's whole picture fit the frame, call `POST /api/moodboards/{board_id}/video-projects/{project_id}/fit-screen` with `{"itemId": "<item id>"}` (the item can be inside a sequence) — the editor's **Fit screen** command (⌘K). It reads the media's real size on the server and saves the project: `zoom` becomes the fitting Scale (`max(1, min(compW/mediaW, compH/mediaH))` for a video or photo, `min(compW/w, compH/h)` of the declared size for an SVG), `scaleX`/`scaleY` become 1 and `panX`/`panY` 0. Rotation and keyframes are kept; `scale` keyframes multiply the new `zoom`. Answer: `{itemId, zoom, mediaWidth, mediaHeight, frame, version}`. 400 for a layer that is not a video or image, 422 when the media size cannot be read.
274
+ A `video` clip (like a bitmap `image`) keeps its own aspect ratio and never gets bars: larger than the composition, it is shrunk to fit; smaller than the composition on both axes, it keeps its pixel size. A 1280×720 clip in a 1920×1080 project covers 1280×720 at `zoom: 1` (set `zoom: 1.5` to fill the frame), and a 1080×1920 clip in a 16:9 project is a centred portrait picture. Whatever the picture does not cover shows the layers below, or the project background.
245
275
 
246
- **SVG clips are vector layers.** An `mediaType: "image"` clip whose `url` ends in `.svg` (or is an `image/svg+xml` data url) is **not** letterbox-fitted to the frame like a bitmap: it is laid out at the size the document itself declares (`width`/`height`, else the `viewBox`, else 300×150 by CSS default sizing) and re-rasterized at whatever resolution it ends up covering, so it stays sharp under any `zoom` or camera push-in. Consequences when authoring: a 200×80 logo occupies 200×80 composition px at `zoom: 1` — set `zoom` (or scale keyframes) to size it, never assume it fills the frame; artwork larger than the composition overflows it. Everything else is an ordinary image clip (filters, transitions, effects, keyframes). An SVG loaded this way cannot reach external resources — fonts must be outlined and images embedded, the same restriction any `<img>`\-loaded SVG has.
276
+ #### Fit screen
247
277
 
248
- **Animated image clips (GIF, animated WebP, APNG).** An `mediaType: "image"` clip whose `url` is an animated GIF, animated WebP or APNG plays as a moving picture instead of freezing on its first frame. Which frame shows is a pure function of the item's clip-local time, so preview and every render agree, and it is resolved from the file's own per-frame delays rather than an assumed frame rate — a file built around one long hold frame plays at the rate it was authored at. Two optional fields on the **item** control playback: `"gifLoopMode"` — `"loop"` (default, restart at the end), `"hold"` (freeze on the last frame) or `"pingpong"` (forwards then backwards) — and `"gifSpeed"`, a playback multiplier from 0.1 to 10, default 1, which changes only how fast the animation runs inside the clip and never the clip's length on the timeline. Both are dropped from the persisted JSON at their defaults, and a value outside those ranges is clamped on load. Set the clip `duration` (and the item's `trimEnd`) to the file's loop length for exactly one play-through — a longer span loops, a shorter one cuts — and `trimStart` offsets where the animation starts. Everything else is an ordinary image clip: plate sizing, filters, transitions, masks, effects and keyframes are unchanged, and still images are unaffected. **`/render-onserver` and `/render-still` bake the animation; the fast `/render` cannot ingest an animated file at all** (its ffmpeg image input assumes a still and errors on one), so send any timeline holding one to `/render-onserver`.
278
+ To make a video or image layer's whole picture fit the frame, call `POST /api/moodboards/{board_id}/video-projects/{project_id}/fit-screen` with `{"itemId": "<item id>"}` (the item can be inside a sequence). It reads the media's real size on the server and saves the project: `zoom` becomes the fitting Scale (`max(1, min(compW/mediaW, compH/mediaH))` for a video or photo, `min(compW/w, compH/h)` of the declared size for an SVG), `scaleX`/`scaleY` become 1 and `panX`/`panY` 0. Rotation and keyframes are kept; `scale` keyframes multiply the new `zoom`. Answer: `{itemId, zoom, mediaWidth, mediaHeight, frame, version}`. 400 for a layer that is not a video or image, 422 when the media size cannot be read.
249
279
 
250
- **Timeline markers.** Optional `"markers": []` at the `data` root — annotations on moments in the timeline (beat points, section starts, review notes). Each marker is `{ "id": "<unique string>", "time": <seconds ≥ 0>, "color": "#rrggbb", "name": "<label, ≤120 chars>" }`. `time` is absolute timeline seconds (a marker past the last clip is legal and kept). `color` must be a hex string; the palette is the 20 canvas label presets (`#ef4444 #f97316 #f59e0b #eab308 #84cc16 #22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9 #3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef #ec4899 #f43f5e #78716c #6b7280 #94a3b8`), default `#8b5cf6` — other hex values load fine but won't match a palette swatch. `name` may be empty. Read/create/move/recolor/rename/delete them by editing this array and `PUT`ting `data` back; invalid entries (missing/negative `time`, non-hex `color`) are dropped on load and duplicate `id`s are de-duplicated (`sanitizeMarkers` in `VideoEditor.jsx`). The array is omitted from the persisted JSON when empty. Annotations only — every render path ignores them.
280
+ #### SVG clips are vector layers
251
281
 
252
- **Layer markers.** Optional `"layerMarkers": []` at the `data` root — the same pin attached to one **clip** instead of to the whole project ("the logo lands here", "cut this shot on this frame"). Each entry is `{ "id": "<unique string>", "itemId": "<an existing timeline item id>", "position": <0–100>, "color": "#rrggbb", "name": "<label, ≤120 chars>" }`. `position` is a percentage offset **inside that item**, the same coordinate `propertyKeyframes` use — **not** timeline seconds. Nothing stores an absolute time; it is derived from the item's current placement, which is why a marker travels with its clip when the clip is moved, trimmed or re-timed. To place one at timeline second `t` on item `i`, compute `position = (t - i.startTime) / i.duration * 100` from the channel layout. Same palette, default colour `#14b8a6`. Sanitized by `sanitizeLayerMarkers` in `VideoEditor.jsx`: entries with a missing/blank `itemId` or a non-numeric `position` are dropped, as are entries whose `itemId` no longer matches a live item (a deleted clip or layer takes its markers with it). The array is omitted from the persisted JSON when empty, and like `markers` it is ignored by every render path.
282
+ An `mediaType: "image"` clip whose `url` ends in `.svg` (or is an `image/svg+xml` data url) is **not** letterbox-fitted to the frame like a bitmap: it is laid out at the size the document itself declares (`width`/`height`, else the `viewBox`, else 300×150 by CSS default sizing) and re-rasterized at whatever resolution it ends up covering, so it stays sharp under any `zoom` or camera push-in. Consequences when authoring: a 200×80 logo occupies 200×80 composition px at `zoom: 1` — set `zoom` (or scale keyframes) to size it, never assume it fills the frame; artwork larger than the composition overflows it. Everything else is an ordinary image clip (filters, transitions, effects, keyframes). An SVG loaded this way cannot reach external resources — fonts must be outlined and images embedded, the same restriction any `<img>`\-loaded SVG has.
253
283
 
254
- **Design tokens (project colour palette).** Optional `"colorTokens": []` at the `data` root — the handful of colours this project reuses (brand, accent, caption bar). Each entry is a **colour string**: `#rgb`, `#rrggbb`, `#rrggbbaa`, or `rgb()`/`rgba()`. Max 24; entries that don't parse are dropped on load and the array is omitted from the persisted JSON when empty (`sanitizeColorTokens` in `frontend/src/components/colorTokens.js`). Duplicates are kept as written. Purely an authoring palette — the editor shows them as the first row of swatches in every colour picker, and **no render path reads them**: a token is not a reference, so setting `colorTokens` does not retint anything on its own.
284
+ #### Animated image clips (GIF, animated WebP, APNG)
285
+
286
+ An `mediaType: "image"` clip whose `url` is an animated GIF, animated WebP or APNG plays as a moving picture instead of freezing on its first frame. Which frame shows is a pure function of the item's clip-local time, so preview and every render agree, and it is resolved from the file's own per-frame delays rather than an assumed frame rate — a file built around one long hold frame plays at the rate it was authored at. Two optional fields on the **item** control playback: `"gifLoopMode"` — `"loop"` (default, restart at the end), `"hold"` (freeze on the last frame) or `"pingpong"` (forwards then backwards) — and `"gifSpeed"`, a playback multiplier from 0.1 to 10, default 1, which changes only how fast the animation runs inside the clip and never the clip's length on the timeline. Both are dropped from the persisted JSON at their defaults, and a value outside those ranges is clamped on load. Set the clip `duration` (and the item's `trimEnd`) to the file's loop length for exactly one play-through — a longer span loops, a shorter one cuts — and `trimStart` offsets where the animation starts. Everything else is an ordinary image clip: plate sizing, filters, transitions, masks, effects and keyframes are unchanged, and still images are unaffected. The fast `/render` fails on an animated file, so render a timeline holding one with `/render-onserver` (what each render path bakes (GreenLight Dash only)).
287
+
288
+ #### Timeline markers
289
+
290
+ Optional `"markers": []` at the `data` root — annotations on moments in the timeline (beat points, section starts, review notes). Each marker is `{ "id": "<unique string>", "time": <seconds ≥ 0>, "color": "#rrggbb", "name": "<label, ≤120 chars>" }`. `time` is absolute timeline seconds (a marker past the last clip is legal and kept). `color` must be a hex string; the palette is the 20 canvas label presets (`#ef4444 #f97316 #f59e0b #eab308 #84cc16 #22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9 #3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef #ec4899 #f43f5e #78716c #6b7280 #94a3b8`), default `#8b5cf6` — other hex values load fine but are outside the palette. `name` may be empty. Read/create/move/recolor/rename/delete them by editing this array and `PUT`ting `data` back; invalid entries (missing/negative `time`, non-hex `color`) are dropped on load and duplicate `id`s are de-duplicated. The array is omitted from the persisted JSON when empty. Annotations only — every render path ignores them.
291
+
292
+ #### Layer markers
293
+
294
+ Optional `"layerMarkers": []` at the `data` root — the same pin attached to one **clip** instead of to the whole project ("the logo lands here", "cut this shot on this frame"). Each entry is `{ "id": "<unique string>", "itemId": "<an existing timeline item id>", "position": <0–100>, "color": "#rrggbb", "name": "<label, ≤120 chars>" }`. `position` is a percentage offset **inside that item**, the same coordinate `propertyKeyframes` use — **not** timeline seconds. Nothing stores an absolute time; it is derived from the item's current placement, which is why a marker travels with its clip when the clip is moved, trimmed or re-timed. To place one at timeline second `t` on item `i`, compute `position = (t - i.startTime) / i.duration * 100` from the channel layout. Same palette, default colour `#14b8a6`. On load, entries with a missing/blank `itemId` or a non-numeric `position` are dropped, as are entries whose `itemId` no longer matches a live item (a deleted clip or layer takes its markers with it). The array is omitted from the persisted JSON when empty, and like `markers` it is ignored by every render path.
295
+
296
+ #### Design tokens (project colour palette)
297
+
298
+ Optional `"colorTokens": []` at the `data` root — the handful of colours this project reuses (brand, accent, caption bar). Each entry is a **colour string**: `#rgb`, `#rrggbb`, `#rrggbbaa`, or `rgb()`/`rgba()`. Max 24; entries that don't parse are dropped on load and the array is omitted from the persisted JSON when empty. Duplicates are kept as written. Purely an authoring palette for the editor, and **no render path reads them**: a token is not a reference, so setting `colorTokens` does not retint anything on its own.
255
299
 
256
300
  Agent rules: when the user asks for "the project's colours", "brand colours" or a palette, read `data.colorTokens` and **copy the literal values** into the fields you are setting (`textColor`, `shapeFillColor`, `background.solidColor`, animator colours (`textAnimators[].effects[].color`), effect colour params, …). When you introduce a colour the project will reuse, append it to `colorTokens` so it shows up in the user's pickers. Rewriting a token does **not** recolour layers that already used its value — change those fields too.
257
301
 
@@ -259,7 +303,9 @@ Agent rules: when the user asks for "the project's colours", "brand colours" or
259
303
  { "colorTokens": ["#8b5cf6", "#0ea5e9", "rgba(0, 0, 0, 0.6)"] }
260
304
  ```
261
305
 
262
- **Photo slots (pictures people may replace).** A picture layer (an item whose clip is `mediaType: "image"`) can carry
306
+ #### Photo slots (pictures people may replace)
307
+
308
+ A picture layer (an item whose clip is `mediaType: "image"`) can carry
263
309
  `"photoSlot": { "fill": "cover" | "fit", "focus": [x, y], "takes": "image" | "media", "series": true | false, "note": "…" }`
264
310
  — a MARK only: no render path reads it. `fill` = cover the layer's box (edges may be cut) or show all of the photo;
265
311
  `focus` = the point (0–1 inside the photo) a Cover crop keeps in view; `takes` = a photo, or a photo or a video;
@@ -267,44 +313,55 @@ Agent rules: when the user asks for "the project's colours", "brand colours" or
267
313
  slot on its own (a logo); `note` (≤ 300 chars) = what belongs there, for an agent. The box is the layer's own frame, so
268
314
  moving or resizing the layer moves the slot. Optional `"photoSlotSettings": { "label": "Photos", "ifFewer": "reuse" | "keep" }`
269
315
  at the `data` root names the series and says what slots with no photo show when people bring fewer photos than slots
270
- (omitted while default). The timeline marks slot layers `SLOT n`; the user sets them in Export for agents ▸ Photo slots.
316
+ (omitted while default).
271
317
  Agent rules: to let people put their own photo in a picture, add `photoSlot` to its item (`{}` is enough — every field
272
318
  has a default); keep the user's existing marks when you rebuild a layer; a slot is never empty — its current picture is
273
319
  the example until someone replaces it.
274
320
 
275
- **Project background.** Set `"background": { …backgroundConfig… }` at the `data` root to fill the stage behind every channel — a solid colour, gradient, GLSL shader, pattern, or image. It takes the same `backgroundConfig` object as shape backgrounds; the full schema, the gradient/shader preset ids, and the shader authoring rules live in the **Backgrounds (project & shape)** section below. Omit it (or use `"type": "transparent"`) for a transparent stage. Baked by **`/render-onserver`**; the fast `/render` ignores it. A bare colour string (`"background": "#3366cc"`) is coerced to `{"type": "solid", "solidColor": …}`; prefer the explicit object.
321
+ #### Project background
322
+
323
+ Set `"background": { …backgroundConfig… }` at the `data` root to fill the stage behind every channel — a solid colour, gradient, GLSL shader, pattern, or image. It takes the same `backgroundConfig` object as shape backgrounds; the full schema, the gradient/shader preset ids and the shader authoring rules are in [video-backgrounds.md](video-backgrounds.md). Omit it (or use `"type": "transparent"`) for a transparent stage. The fast `/render` does not draw it (what each render path bakes (GreenLight Dash only)). A bare colour string (`"background": "#3366cc"`) is coerced to `{"type": "solid", "solidColor": …}`; prefer the explicit object.
276
324
 
277
- **One live item per channel.** Treat every channel as one timeline track, not as a container of simultaneously composited layers. At any timeline time, a channel may have at most one active item. If two items' visible windows overlap, only one is guaranteed to render (the later-starting item commonly wins) and the save API does not reject the project. For an unsnapped item, its ordinary visible window is `freeStart, freeStart + (trimEnd - trimStart))`; validate these windows before `PUT`. Put every simultaneously visible image, shape, text block, motion layer, or other visual on a separate channel. This rule also applies inside sequence timelines. Reuse one channel for consecutive, non-overlapping captions or shots only.
325
+ #### Motion blur (render-only)
278
326
 
279
- **Keep unsnapped items sorted by `freeStart`.** On an `unsnapped` channel the editor treats the item BEFORE a clip in the `items` array as "the clip before it" — cross transitions, the seam button and the layout's overlap all read array neighbours, not time neighbours. The editor re-sorts on every insert, move and paste; an API write does not. After adding or moving items, sort each unsnapped channel's `items` by `freeStart` before `PUT`. An out-of-order array looks fine until the user adds a cross transition: the clip is then snapped against the wrong neighbour and jumps elsewhere on the timeline (e.g. past the end), so it appears to vanish.
327
+ Set `"motionBlurEnabled": true` at the `data` root — a project-wide switch, not per layer — to bake cinematic shutter blur into full renders. The choice is made per output **frame**, not per layer: a frame where motion is detected is rendered as a multi-sample accumulation of the **whole composition** across a shutter window centred on the frame, so every layer that changes inside that window blurs (keyframed layers, text animators, motion layers, transitions, particles), not only the one that triggered it. A frame with no detected motion renders once and stays sharp, so cost only grows where motion exists.
280
328
 
281
- **Motion blur (render-only).** Set `"motionBlurEnabled": true` at the `data` root — a project-wide switch, not per layer — to bake cinematic shutter blur into full renders. The choice is made per output **frame**, not per layer: a frame where motion is detected is rendered as a multi-sample accumulation of the **whole composition** across a shutter window centred on the frame, so every layer that changes inside that window blurs (keyframed layers, text animators, motion layers, transitions, particles), not only the one that triggered it. A frame with no detected motion renders once and stays sharp, so cost only grows where motion exists.
329
+ Two things are not re-sampled across that window:
330
+
331
+ - **Footage.** A `video` layer (top level or inside a `sequence`) shows the frame's own picture in every sub-frame: real footage already carries its camera's blur, so it is never blended with its neighbouring frames. The layer's own movement (its keys, a parent, the Camera layer moving an unlocked footage channel) still blurs; the picture inside it is one source frame. A speed ramp is not motion by itself.
332
+ - **A Final 3D Scene whose world is all that moves.** When the only things moving across the window are inside a Scene in `"mode": "final"` (its objects', flat layers' and camera's keys, the Camera layer that films it), the frame is traced **once**: every sample gets its own time inside the shutter, so the blur is continuous (no stepped copies) and the frame costs about what a sharp one does. Anything else moving on that frame (another layer, the Rig's own keys, a model layer that moves) renders it the accumulated way ([video-world3d.md](video-world3d.md#motion-blur)).
282
333
 
283
334
  A frame is accumulated when any of these change across its shutter window:
284
335
 
285
336
  - the camera layer's pose (any camera track), or the frame enters or leaves a camera clip;
286
337
  - a keyframed layer's resolved pose: its position, rotation or scale as drawn, from any track that places it (`translateX/Y/Z`, `xPercent`/`yPercent`, `rotate`, `rotateX/Y`, `scale`, `scaleX`/`scaleY`), and a live motion path's `motionPathT`, `motionPathOffsetX/Y/Z` or keyed path shape (adjustment-layer pan/zoom included);
287
338
  - the parent of a parented layer, or the tracked point of a `motionFollow` layer;
288
- - a `video` item with a speed curve (any `speedPoints` value other than 1): every frame of that item, the 1× stretches included;
289
339
  - a particle system, or an `html` clip drawn live (the render default): every frame they are on screen;
290
- - the inner timeline of a `sequence` clip, by the same rules, down to the 4-level nesting limit (parenting and `motionFollow` links inside a sequence are not read).
340
+ - the inner timeline of a `sequence` clip, by the same rules, down to the 4-level nesting limit (parenting and `motionFollow` links inside a sequence are not read);
341
+ - letters a Text Animator moves, on 2D text and 3D Text alike: a keyed track while its value changes, a Time cascade while a letter is in flight, Flow, Wiggle, Scramble and a blinking caret (a static offset or a cascade at rest is still);
342
+ - a `model3d` layer's own GLB clips (`model3dConfig.animation`) while they play and the pose changes: a `once` clip past its end, keys that hold and speed 0 are still;
343
+ - the Rig's own keyframes (`sc:` / `scc:` tracks), and what moves inside a 3D Scene: its objects', flat layers' and camera's keys, bodies, cloth, particles, smoke, matter and a `.blend` import's baked playback ([video-world3d.md](video-world3d.md#motion-blur)).
291
344
 
292
- Nothing else triggers a frame by itself: text animators, MotionFast `motion` layers, 3D model animation, enter/exit and cross transitions, keyed opacity or effect params, and movement inside footage at normal speed. They blur on frames something in the list triggers and are sharp everywhere else.
345
+ Nothing else triggers a frame by itself: MotionFast `motion` layers, enter/exit and cross transitions, keyed opacity or effect params, and movement inside footage at normal speed. They blur on frames something in the list triggers and are sharp everywhere else. The same rules hold inside a `sequence` clip.
293
346
 
294
347
  **Strength** is `"motionBlurShutter"` — a **shutter angle in degrees** alongside `motionBlurEnabled`. Only these four values are meaningful; anything else (missing, non-numeric, an off-table angle) snaps to the nearest level, so `180` is the effective default:
295
348
 
296
- | Angle | Level | Samples | Cost on moving frames |
297
- | --- | --- | --- | --- |
298
- | `90` | Subtle | 8 | ~8× |
299
- | `180` | Normal — cinematic default | 16 | ~16× |
300
- | `270` | Strong | 24 | ~24× |
301
- | `360` | Max | 32 | ~32× |
349
+ | Angle | Level | Samples | Cost on accumulated frames | Final Scene traced once |
350
+ | --- | --- | --- | --- | --- |
351
+ | `90` | Subtle | 8 | ~8× | ~1× |
352
+ | `180` | Normal — cinematic default | 16 | ~16× (a Final Scene's: 16 traces, each denoised) | ~1× |
353
+ | `270` | Strong | 24 | ~24× | ~1× |
354
+ | `360` | Max | 32 | ~32× | ~1× |
355
+
356
+ The angle is how much of each frame interval the exposure covers, so wider = more blur; on accumulated frames also proportionally slower. The field is only stored while blur is on. Blur is baked by the full renders (`/render-onserver`, `/render-onserver-job`, the desktop Download) and never shown in the editor preview; the fast `/render` ignores it. Worth enabling for projects with fast zooms/pans that would otherwise strobe. Renders get proportionally slower on accumulated frames (~16× each at the default), and a particle system or a live html clip makes every frame it covers an accumulated one. A frame a Final Scene traces once with its motion keeps that Scene's cost. Putting a 3D object into footage (tracking-data.md (GreenLight Dash only)) sets the angle from the footage's own blur.
302
357
 
303
- The angle is how much of each frame interval the exposure covers, so wider = more blur *and* proportionally slower. The field is only written when blur is on, so projects that never enabled it round-trip unchanged. Table lives in `frontend/src/components/videoEditor/motionBlurLevels.js`. Honored by **`/render-onserver`**, **`/render-onserver-job`** and the desktop Download/Render — **ignored by the fast `/render`** and never applied in the editor preview (render-only by design). Worth enabling for projects with fast zooms/pans that would otherwise strobe. Renders get proportionally slower on accumulated frames (~16× each at the default), and a speed-ramped clip, a particle system or a live html clip makes every frame it covers an accumulated one.
358
+ #### Renderer
304
359
 
305
- **Renderer.** Every project renders on the engine the app is set to — one app-wide setting, not a project field. Read it with `GET /api/video-editor/engine` (`engine`: `"webgl"`, the default, or `"webgpu"`). Preview and the desktop Download use it; the headless renders (`/render-onserver`, `/render-onserver-job`, `/render-still`) take it as the request's `"renderer"` field, which defaults to `"webgl"`, so pass the engine there to render what the editor shows. It matters wherever shading is written by hand rather than picked from a list: an uploaded Advanced Transition package is authored for one engine and is refused on the other, and a **custom** adjustment/layer effect (`"id": "custom"`) cannot run on WebGPU at all — its body is compiled at runtime, so the effect is skipped with a reason. Building a new template or transition: [custom-builds.md (GreenLight Dash only).
360
+ Every project renders on the engine the app is set to — one app-wide setting, not a project field. Read it with `GET /api/video-editor/engine` (`engine`: `"webgl"`, the default, or `"webgpu"`). Preview and the desktop Download use it; the headless renders (`/render-onserver`, `/render-onserver-job`, `/render-still`) take it as the request's `"renderer"` field, which defaults to `"webgl"`, so pass the engine there to render what the editor shows. A 3D Scene ([video-world3d.md](video-world3d.md)) is the exception: it draws only on WebGPU, so a project with one renders and stills on `"webgpu"` whatever the setting or the request says, and an editor on WebGL shows a notice in its place. It matters wherever shading is written by hand rather than picked from a list: an uploaded Advanced Transition package is authored for one engine and is refused on the other, and a **custom** adjustment/layer effect (`"id": "custom"`) cannot run on WebGPU at all — its body is compiled at runtime, so the effect is skipped with a reason. Building a new template or transition: custom-builds.md (GreenLight Dash only).
306
361
 
307
- **Sound & audio fades.** Clips with audio (`video` and `audio` media types) honor per-item `volume` (0–100 %, default 100; 0 also writes `muted: true`) and an intro/outro fade: `audioFadeIn` / `audioFadeOut` (seconds of the item's **placed** span; 0/absent = off; only stored when > 0) plus one `audioFadeCurve` for both ends (`"linear" | "smooth" | "exponential"`; only stored when it differs from the `"smooth"` default). The envelope multiplies onto `volume`; on a clip shorter than its two fades they overlap and multiply into a bump that never reaches full level. Fades span the item's placed bounds, so a partial (`rangeStart`/`rangeEnd`) render keeps the same shape. Applied identically in preview and the WebCodecs export paths (desktop Download and `/render-onserver`); the fast `/render` ignores dedicated audio clips entirely.
362
+ #### Sound & audio fades
363
+
364
+ Clips with audio (`video` and `audio` media types) honor per-item `volume` (0–100 %, default 100). `volume` is the sound control: mute a layer with `volume: 0`, un-mute it with the level you want; `muted` only follows it (`true` exactly when `volume` is 0). A write that sets just `muted: true` is stored as `volume: 0` with the old level in `preMuteVolume`, and just `muted: false` on a muted layer brings that level back. Clips with audio also take an intro/outro fade: `audioFadeIn` / `audioFadeOut` (seconds of the item's **placed** span; 0/absent = off; only stored when > 0) plus one `audioFadeCurve` for both ends (`"linear" | "smooth" | "exponential"`; only stored when it differs from the `"smooth"` default). The envelope multiplies onto `volume`; on a clip shorter than its two fades they overlap and multiply into a bump that never reaches full level. Fades span the item's placed bounds, so a partial (`rangeStart`/`rangeEnd`) render keeps the same shape. Applied identically in preview and the full renders (desktop Download and `/render-onserver`); the fast `/render` cannot render `audio` clips (what each render path bakes (GreenLight Dash only)).
308
365
 
309
366
  ### Sequence clips (`mediaType: "sequence"`)
310
367
 
@@ -337,28 +394,28 @@ Rules:
337
394
 
338
395
  - **Nested state is self-contained** — inner `items[].clipId` must reference ids inside `sequence.clips`, not the parent's `clips`. Inner channels/items use the exact same schema as top-level ones (text/shape/audio/video all allowed; sequences-in-sequences too).
339
396
  - **`duration` is derived** — on load the editor recomputes the clip's `duration` from the nested channels' total length, so keep the nested timeline the length you want and set `duration`/`trimEnd` to match.
340
- - Prefer `"unsnapped": true` + `freeStart` on inner channels — that's what the editor's own "Group as sequence" produces.
397
+ - Prefer `"unsnapped": true` + `freeStart` on inner channels — that's what the editor produces when it groups layers into a sequence.
341
398
  - The parent timeline item referencing the sequence clip is a **normal item** (`{ "id", "clipId": "seq1", "trimStart": 0, "trimEnd": 6, ... }`) — trim it to show only part of the sequence, add keyframes/filters/transitions to affect the whole block.
342
399
  - **A sequence with `essential`** is a built edit that carries its own recipe. There are two kinds:
343
- - **An AI Viral Edit:** `"essential": { "kind": "viral-edit", "v": 3, "recipe": { "settings", "analysisUrl" (or "analysis"), "cutoutUrl", "sourceUrl", "itemId", "editId", "W", "H", "fps", "at", "timeOffset", "duration" } }` (older edits carry `"v": 1` or `2`; a rebuild turns them into v3).
400
+ - **An AI Viral Edit:** `"essential": { "kind": "viral-edit", "v": 3, "recipe": { "settings", "analysisUrl" (or "analysis"), "cutoutUrl", "sourceUrl", "itemId", "editId", "W", "H", "fps", "at", "timeOffset", "duration" } }` (an edit with `"v": 1` or `2` becomes v3 when rebuilt).
344
401
  - The editor rebuilds the layers inside the sequence from the recipe whenever the user changes one of its settings. A rebuild replaces any hand edits made inside the sequence.
345
402
  - To change such an edit, rebuild it with the `viral-edits` workflow (its build step rebuilds in place). Edit its inner layers by hand only for a one-word change the user asked for.
346
403
  - **A HyperFrames scene** (made by Convert to scenes): `"essential": { "kind": "hyperframes-scene", "v": 1, "recipe": { "url", "sceneId", "label", "clipValues", "compositionVariables", "sceneVariables", "sceneDefaults", "values", "sceneValues" } }`.
347
- - Its Essential panel sets per-scene values. A change rewrites only the `gl-vars` of the html pages inside the sequence; the layers, keys and camera stay.
404
+ - Its per-scene values change only the `gl-vars` of the html pages inside the sequence; the layers, keys and camera stay.
348
405
  - To change the values, call `…/html-clips/{item_id}/hyperframes-scene-values` (hyperframes.md (GreenLight Dash only)). Do not edit the pages' urls by hand.
349
406
  - When you write the project, keep `essential` exactly as it is. Without it, the edit can no longer be rebuilt.
350
407
  - The editor drops an `essential` sequence clip from `clips` once no item uses it.
351
- - **Rendering:** `/render-onserver` bakes sequences in fully; the fast `/render` rejects them with 400. Adjustment layers inside a sequence composite (grade + effects chain + layer mask alike). Known nested limits: the project's global Depth of Field doesn't composite inside a sequence (a sequence's own camera clip's depth of field does: AI Viral Edits use it), and inner audio only exports when the sequence item plays at flat 1× speed.
408
+ - **Rendering:** the full renders bake sequences in; the fast `/render` rejects them (what each render path bakes (GreenLight Dash only)). Adjustment layers inside a sequence composite (grade + effects chain + layer mask alike). Known nested limits: the project's global Depth of Field doesn't composite inside a sequence (a sequence's own camera clip's depth of field does: AI Viral Edits use it), and inner audio only exports when the sequence item plays at flat 1× speed.
352
409
  - Keep ids unique across the whole document, inner timelines included.
353
410
 
354
411
  ### Video presets (save & load reusable sequences)
355
412
 
356
- Presets are **global** (not per-board) JSON files under `media/video-presets/` — one file per preset. Format:
413
+ Presets are **global** (not per-board) JSON files under `media/video-presets/` — one file per preset. Format (`sequence` holds the `clips` and `channels` of a sequence clip, as in [Sequence clips](#sequence-clips-mediatype-sequence)):
357
414
 
358
415
  ```json
359
416
  { "version": 1, "kind": "ve-sequence-preset", "name": "Lower third", "duration": 4.0,
360
417
  "videoSize": { "w": 1920, "h": 1080 },
361
- "sequence": { "clips": [ ... ], "channels": [ ... ] } }
418
+ "sequence": { "clips": [], "channels": [] } }
362
419
  ```
363
420
 
364
421
  Endpoints:
@@ -376,16 +433,31 @@ POST /api/video-presets/import?board_id={board_id} # multipart `file` = a pr
376
433
  - **Save a preset** from an existing sequence clip: take the clip's `sequence` payload + `duration` and `POST` it with a name.
377
434
  - **Load a preset into a project**: `GET` the preset, then add to the project `data` (1) a new sequence clip whose `sequence` is the preset's `sequence`, and (2) a timeline item referencing it on an `unsnapped` channel with a `freeStart`. **Generate fresh ids for every clip/channel/item in the preset payload first** (recursively, remapping inner `clipId` references) — inserting the same preset twice with original ids corrupts the project. Then `PUT` the project back and render with `/render-onserver`.
378
435
  - **Cross-machine sharing**: use the export/import bundle endpoints above — export packs the referenced media files into the .zip, and import restores them (skipping byte-identical files that already exist) and remaps the URLs, so the preset plays anywhere.
379
- - Video PROJECTS have the same bundle endpoints: `GET /api/moodboards/{board_id}/video-projects/{project_id}/export` (zip with `project.json` + media) and `POST /api/moodboards/{board_id}/video-projects/import` (multipart `file` = that zip, or a bare editor-state .json) which creates a NEW project in the board with media imported and URLs remapped.
436
+
437
+ ### Project export and import (.zip bundles)
438
+
439
+ Video projects have the same bundle endpoints as presets:
440
+
441
+ ```
442
+ GET /api/moodboards/{board_id}/video-projects/{project_id}/export # a .zip with project.json + every /media file it uses
443
+ POST /api/moodboards/{board_id}/video-projects/import # multipart `file` = that zip, or a bare editor-state .json
444
+ POST /api/moodboards/{board_id}/video-projects/{project_id}/export-job # the same zip as a background job
445
+ GET /api/video-project-export-jobs/{id}
446
+ DELETE /api/video-project-export-jobs/{id}
447
+ ```
448
+
449
+ - `import` creates a NEW project in the board, with the media imported and the URLs remapped.
450
+ - The one-shot `GET .../export` answers only after the whole zip is written: minutes for a project with gigabytes of media, with no way to see progress or stop it. For big projects start a job instead. `export-job` returns at once with `{id, status, phase, progress, filesDone, filesTotal, bytesDone, bytesTotal, currentFile, missing, filename, downloadUrl}` (a project that is already exporting returns its running job).
451
+ - Poll `GET /api/video-project-export-jobs/{id}` until `status` is `completed`, `failed` or `cancelled`, then `GET` its `downloadUrl`. `missing` counts referenced media files that are gone from disk and were left out.
452
+ - `DELETE` cancels a running job or deletes a finished one's zip; a zip nobody deletes expires after an hour.
380
453
 
381
454
  ### GreenLight Motion pages (Export for agents)
382
455
 
383
- A project leaves the app through Video Editor ▸ project chip ▸ **Export for agents**, as ONE of:
384
- - **a `.glvideo` file** (the window's default, `format: "glvideo"`): the project in one file, for a Claude chat (drop it
385
- on the GreenLight Motion connector's video page) or another GreenLight Dash (double-click). Layout below.
456
+ A project leaves the app (`export-light`, below) as ONE of:
457
+ - **a `.glvideo` file** (`format: "glvideo"`): the project in one file, for a Claude chat (the GreenLight Motion
458
+ connector's video page) or another GreenLight Dash (`import-light`). Layout below.
386
459
  - **a GreenLight Motion Engine page** (`format: "folder"`, the API's default): a folder with `index.html` (plays the
387
- project in Chrome/Edge from a double-click, nothing to install: the studio — preview, layers, options, Notes,
388
- Render MP4), `project.json` (the editor `data` above + `name`, `formatVersion: 1` and `template` — the options), and
460
+ project in Chrome/Edge with nothing to install, with its layers, options and notes, and renders an MP4), `project.json` (the editor `data` above + `name`, `formatVersion: 1` and `template` — the options), and
389
461
  `assets/` (`assets/media/<rel>` = the board's `/media/<rel>` files under their original refs, `assets/hdr/`,
390
462
  `assets/models/`). For an agent on this computer that works on `project.json`.
391
463
 
@@ -395,7 +467,7 @@ Over the API:
395
467
  GET /api/moodboards/{board_id}/video-projects/{project_id}/light-report
396
468
  → { name, template | null, leftOut: [{ kind, reason, layers[] }], engine: bool, defaultDest }
397
469
  POST /api/moodboards/{board_id}/video-projects/{project_id}/export-light
398
- { "dest?": "/abs/parent/folder", "title?": "…", "template?": { "title", "fields": [ … ] },
470
+ { "dest?": "/abs/parent/folder", "template?": { "title", "fields": [ … ] },
399
471
  "modelFiles?": { "<preset id>": "<the URL the app serves its .glb at>" }, "format?": "folder" | "glvideo",
400
472
  "pack?": false, "smaller?": false, "progressId?": "<8–64 of A-Z a-z 0-9 ->" }
401
473
  → folder: { folder, index, packed: { path, bytes } | null, files, bytes, missing, leftOut, fields, smaller }
@@ -416,27 +488,32 @@ GET /api/moodboards/{board_id}/video-projects/{project_id}/glvideo?models=<JSON
416
488
  project) or to a chat: the GreenLight Motion connector's `open_video` page reads it in the user's browser, and
417
489
  `get_video` / `edit_video` change it there.
418
490
 
419
- - The window's **Photo slots** strip writes each picture's `photoSlot` mark into the project (one undo step, saved before
420
- the export), and every slot becomes an `image` field of the template (its clip's `url` + `proxyUrl`); the marks travel
421
- inside `project.json` with their layers.
491
+ - Each picture marked with `photoSlot` in the project (saved before the export) becomes an `image` field of the template
492
+ (its clip's `url` + `proxyUrl`); the marks travel inside `project.json` with their layers.
422
493
  - `template.fields[]` = `{ key, label, type: text|color|image|number|select|toggle, targets: [{ itemId | clipId | (neither = project), path }] }`
423
494
  — e.g. `{ "key": "headline", "label": "Headline", "type": "text", "targets": [{ "itemId": "<item id>", "path": "textContent" }] }`,
424
495
  a shape colour `path: "shapeFillColor"`, the background `{ "path": "background.solidColor" }`. Omit `template` to keep
425
496
  the project's saved one; `pack: true` (folder only) also writes one self-contained `.html` next to the folder (for a
426
497
  Claude artifact: keep it under 16 MB). `smaller: true` puts WAV sounds in as MP3 and PNG pictures as WebP (transparency
427
498
  kept) and points the exported project at them — the board keeps its originals; `smaller` in the answer is
428
- `{ sounds, pictures, before, after }`. A name already taken gets a number: `<title> 2.glvideo`, `<title> 2/`.
499
+ `{ sounds, pictures, before, after }`. The endpoint has no top-level `title`: name the export through `template.title`.
500
+ A name already taken gets a number: `<title> 2.glvideo`, `<title> 2/`.
429
501
  - The template is kept beside the project (`<board folder>/.motion/<project id>.template.json`), not in its `data`.
430
502
  - `import-light` copies every file into the board (`motion-<title>/`), except a `/media/` file this machine already
431
- has byte for byte; the template comes back too. It is what the page's **Open in GreenLight Dash** button does
432
- (`greenlightdash://open?path=…`, handled by the desktop app on the board that is open).
503
+ has byte for byte; the template comes back too. The page's `greenlightdash://open?path=…` link does the same
504
+ (handled by the desktop app on the board that is open).
433
505
  - `leftOut` names layers the page does not play (MotionFast templates, After Effects clips) — they stay in the data.
434
- - The one-shot `GET .../export` answers only after the whole zip is written — minutes for a project with gigabytes of media, with no way to see progress or stop it. For big projects start a job instead: `POST /api/moodboards/{board_id}/video-projects/{project_id}/export-job` returns at once with `{id, status, phase, progress, filesDone, filesTotal, bytesDone, bytesTotal, currentFile, missing, filename, downloadUrl}` (a project that is already exporting returns its running job). Poll `GET /api/video-project-export-jobs/{id}` until `status` is `completed`, `failed` or `cancelled`, then `GET` its `downloadUrl`; `missing` counts referenced media files that are gone from disk and were left out. `DELETE /api/video-project-export-jobs/{id}` cancels a running job or deletes a finished one's zip; a zip nobody deletes expires after an hour.
435
506
 
436
507
  ### Interface scenes (built-in animated scenes — "the UI kit")
437
508
 
438
- The editor's **Presets** tab also lists the **Interface scenes** library (called GL Motion before 14.0.25): 172 ready-made animated scenes (controls, cards, charts, checkout, coupons, countdowns, galleries…) in one style, all transparent (they sit over the video). Search and category chips filter them; Light / Dark and an accent recolour them. A card's **HTML clip** button inserts the scene as one standalone transparent HTML page, saved into the board's media (`ui-kit/<id>.html`) and placed as an HTML clip (`mediaType: "html"`) at the scene's own length.
509
+ The **Interface scenes** library holds 172 ready-made animated scenes (controls, cards, charts, checkout, coupons, countdowns, galleries…) in one style, all transparent (they sit over the video), each in a light or dark theme with an accent colour. Inserting one makes the scene one standalone transparent HTML page, saved into the board's media (`ui-kit/<id>.html`) and placed as an HTML clip (`mediaType: "html"`) at the scene's own length.
439
510
 
440
511
  For a **whole film** — a story and its shots with the user's copy and pictures, a scenario card on the board, a voice-over, sound design and a render; with a GLEA delivery the film finished in a Video Editor project (a tutorial's interface as HTML scene clips, other films built with the editor's own layers) and rendered from it — use the `motion-film` workflow on the `greenlight` MCP server (SKILL.md (GreenLight Dash only)) instead of inserting scenes one by one.
441
512
 
442
- The library is the app's own, at `$GL_MOTION_KIT_DIR` in GLAI and GLEA terminals (its engine and scenes; `registry.json` beside them lists every scene with its editable texts and photo slots). To make one scene's page without the UI: `node -e "const fs=require('fs'),path=require('path'),vm=require('vm');const D=process.env.GL_MOTION_KIT_DIR,src=(f)=>fs.readFileSync(path.join(D,f),'utf8');globalThis.window=globalThis;const run=(f)=>{if(window.UIK)UIK.__loadingFile=f;vm.runInThisContext(src(f),{filename:f});};['easings.js','engine.js','catalog.js'].forEach(run);UIK.FILES.forEach(run);run('html-export.js');const el=UIK.elements.find((e)=>e.id==='odometer');console.log(UIK.toHTML(el,{theme:'light',sources:{easings:src('easings.js'),engine:src('engine.js'),files:[src(el.file)]}}));" > odometer.html`, then upload it and place it as an HTML clip.
513
+ The library is the app's own, at `$GL_MOTION_KIT_DIR` in every GLAI terminal (its engine and scenes; `registry.json` beside them lists every scene with its editable texts and photo slots). To make one scene's page yourself (here the `odometer` scene):
514
+
515
+ ```bash
516
+ node -e "const fs=require('fs'),path=require('path'),vm=require('vm');const D=process.env.GL_MOTION_KIT_DIR,src=(f)=>fs.readFileSync(path.join(D,f),'utf8');globalThis.window=globalThis;const run=(f)=>{if(window.UIK)UIK.__loadingFile=f;vm.runInThisContext(src(f),{filename:f});};['easings.js','engine.js','catalog.js'].forEach(run);UIK.FILES.forEach(run);run('html-export.js');const el=UIK.elements.find((e)=>e.id==='odometer');console.log(UIK.toHTML(el,{theme:'light',sources:{easings:src('easings.js'),engine:src('engine.js'),files:[src(el.file)]}}));" > odometer.html
517
+ ```
518
+
519
+ Then add the page as an html clip: write it into the board folder and pass its url as `htmlUrl`, or send its text as `html`, to `POST …/html-clips` (GreenLight Dash only).