greenlight-motion 0.3.3 → 0.4.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.
- package/README.md +15 -10
- package/dist/bundle-inputs.json +23 -1
- package/dist/greenlight-motion-script.mjs +549 -80
- package/dist/greenlight-motion.js +1078 -747
- package/dist/greenlight-motion.mjs +44077 -41999
- package/dist/script-inputs.json +3 -1
- package/package.json +1 -1
- package/schema/capabilities.json +1 -1
- package/skill/SKILL.md +10 -10
- package/skill/references/scene-script.md +2 -2
- package/skill/references/video-3d.md +107 -100
- package/skill/references/video-backgrounds.md +89 -41
- package/skill/references/video-camera.md +158 -83
- package/skill/references/video-design.md +39 -7
- package/skill/references/video-effects.md +936 -620
- package/skill/references/video-grid.md +24 -24
- package/skill/references/video-keyframes.md +145 -52
- package/skill/references/video-particles.md +48 -29
- package/skill/references/video-paths.md +69 -65
- package/skill/references/video-projects.md +159 -89
- package/skill/references/video-shapes.md +94 -31
- package/skill/references/video-text-kinetic.md +78 -27
- package/skill/references/video-text-presets.md +738 -17
- package/skill/references/video-text.md +171 -79
- package/skill/references/video-transitions.md +72 -17
|
@@ -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
|
|
10
|
-
|
|
11
|
-
##
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
212
|
+
"version": 2,
|
|
218
213
|
"videoSize": { "w": 1920, "h": 1080 },
|
|
219
214
|
"motionBlurEnabled": false,
|
|
220
|
-
"clips": [
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
229
257
|
|
|
230
|
-
|
|
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).
|
|
231
259
|
|
|
232
|
-
|
|
260
|
+
#### Blend mode
|
|
233
261
|
|
|
234
|
-
|
|
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`.
|
|
235
263
|
|
|
236
|
-
|
|
264
|
+
#### Mirror
|
|
237
265
|
|
|
238
|
-
|
|
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.
|
|
239
267
|
|
|
240
|
-
|
|
268
|
+
#### Layer titles
|
|
241
269
|
|
|
242
|
-
|
|
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.
|
|
243
271
|
|
|
244
|
-
|
|
272
|
+
#### Videos and photos draw at their real size
|
|
245
273
|
|
|
246
|
-
|
|
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.
|
|
247
275
|
|
|
248
|
-
|
|
276
|
+
#### Fit screen
|
|
249
277
|
|
|
250
|
-
|
|
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.
|
|
251
279
|
|
|
252
|
-
|
|
280
|
+
#### SVG clips are vector layers
|
|
253
281
|
|
|
254
|
-
|
|
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.
|
|
283
|
+
|
|
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
|
-
|
|
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,18 +313,18 @@ 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).
|
|
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
|
-
|
|
321
|
+
#### Project background
|
|
276
322
|
|
|
277
|
-
|
|
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.
|
|
278
324
|
|
|
279
|
-
|
|
325
|
+
#### Motion blur (render-only)
|
|
280
326
|
|
|
281
|
-
|
|
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.
|
|
282
328
|
|
|
283
329
|
A frame is accumulated when any of these change across its shutter window:
|
|
284
330
|
|
|
@@ -300,11 +346,15 @@ Nothing else triggers a frame by itself: text animators, MotionFast `motion` lay
|
|
|
300
346
|
| `270` | Strong | 24 | ~24× |
|
|
301
347
|
| `360` | Max | 32 | ~32× |
|
|
302
348
|
|
|
303
|
-
The angle is how much of each frame interval the exposure covers, so wider = more blur *and* proportionally slower. The field is only
|
|
349
|
+
The angle is how much of each frame interval the exposure covers, so wider = more blur *and* 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 speed-ramped clip, a particle system or a live html clip makes every frame it covers an accumulated one.
|
|
350
|
+
|
|
351
|
+
#### Renderer
|
|
304
352
|
|
|
305
|
-
|
|
353
|
+
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).
|
|
306
354
|
|
|
307
|
-
|
|
355
|
+
#### Sound & audio fades
|
|
356
|
+
|
|
357
|
+
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
358
|
|
|
309
359
|
### Sequence clips (`mediaType: "sequence"`)
|
|
310
360
|
|
|
@@ -337,28 +387,28 @@ Rules:
|
|
|
337
387
|
|
|
338
388
|
- **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
389
|
- **`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
|
|
390
|
+
- Prefer `"unsnapped": true` + `freeStart` on inner channels — that's what the editor produces when it groups layers into a sequence.
|
|
341
391
|
- 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
392
|
- **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" } }` (
|
|
393
|
+
- **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
394
|
- 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
395
|
- 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
396
|
- **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
|
|
397
|
+
- Its per-scene values change only the `gl-vars` of the html pages inside the sequence; the layers, keys and camera stay.
|
|
348
398
|
- 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
399
|
- When you write the project, keep `essential` exactly as it is. Without it, the edit can no longer be rebuilt.
|
|
350
400
|
- The editor drops an `essential` sequence clip from `clips` once no item uses it.
|
|
351
|
-
- **Rendering:**
|
|
401
|
+
- **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
402
|
- Keep ids unique across the whole document, inner timelines included.
|
|
353
403
|
|
|
354
404
|
### Video presets (save & load reusable sequences)
|
|
355
405
|
|
|
356
|
-
Presets are **global** (not per-board) JSON files under `media/video-presets/` — one file per preset. Format:
|
|
406
|
+
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
407
|
|
|
358
408
|
```json
|
|
359
409
|
{ "version": 1, "kind": "ve-sequence-preset", "name": "Lower third", "duration": 4.0,
|
|
360
410
|
"videoSize": { "w": 1920, "h": 1080 },
|
|
361
|
-
"sequence": { "clips": [
|
|
411
|
+
"sequence": { "clips": [], "channels": [] } }
|
|
362
412
|
```
|
|
363
413
|
|
|
364
414
|
Endpoints:
|
|
@@ -376,16 +426,31 @@ POST /api/video-presets/import?board_id={board_id} # multipart `file` = a pr
|
|
|
376
426
|
- **Save a preset** from an existing sequence clip: take the clip's `sequence` payload + `duration` and `POST` it with a name.
|
|
377
427
|
- **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
428
|
- **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
|
-
|
|
429
|
+
|
|
430
|
+
### Project export and import (.zip bundles)
|
|
431
|
+
|
|
432
|
+
Video projects have the same bundle endpoints as presets:
|
|
433
|
+
|
|
434
|
+
```
|
|
435
|
+
GET /api/moodboards/{board_id}/video-projects/{project_id}/export # a .zip with project.json + every /media file it uses
|
|
436
|
+
POST /api/moodboards/{board_id}/video-projects/import # multipart `file` = that zip, or a bare editor-state .json
|
|
437
|
+
POST /api/moodboards/{board_id}/video-projects/{project_id}/export-job # the same zip as a background job
|
|
438
|
+
GET /api/video-project-export-jobs/{id}
|
|
439
|
+
DELETE /api/video-project-export-jobs/{id}
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
- `import` creates a NEW project in the board, with the media imported and the URLs remapped.
|
|
443
|
+
- 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).
|
|
444
|
+
- 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.
|
|
445
|
+
- `DELETE` cancels a running job or deletes a finished one's zip; a zip nobody deletes expires after an hour.
|
|
380
446
|
|
|
381
447
|
### GreenLight Motion pages (Export for agents)
|
|
382
448
|
|
|
383
|
-
A project leaves the app
|
|
384
|
-
- **a `.glvideo` file** (
|
|
385
|
-
|
|
449
|
+
A project leaves the app (`export-light`, below) as ONE of:
|
|
450
|
+
- **a `.glvideo` file** (`format: "glvideo"`): the project in one file, for a Claude chat (the GreenLight Motion
|
|
451
|
+
connector's video page) or another GreenLight Dash (`import-light`). Layout below.
|
|
386
452
|
- **a GreenLight Motion Engine page** (`format: "folder"`, the API's default): a folder with `index.html` (plays the
|
|
387
|
-
project in Chrome/Edge
|
|
388
|
-
Render MP4), `project.json` (the editor `data` above + `name`, `formatVersion: 1` and `template` — the options), and
|
|
453
|
+
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
454
|
`assets/` (`assets/media/<rel>` = the board's `/media/<rel>` files under their original refs, `assets/hdr/`,
|
|
390
455
|
`assets/models/`). For an agent on this computer that works on `project.json`.
|
|
391
456
|
|
|
@@ -395,7 +460,7 @@ Over the API:
|
|
|
395
460
|
GET /api/moodboards/{board_id}/video-projects/{project_id}/light-report
|
|
396
461
|
→ { name, template | null, leftOut: [{ kind, reason, layers[] }], engine: bool, defaultDest }
|
|
397
462
|
POST /api/moodboards/{board_id}/video-projects/{project_id}/export-light
|
|
398
|
-
{ "dest?": "/abs/parent/folder", "
|
|
463
|
+
{ "dest?": "/abs/parent/folder", "template?": { "title", "fields": [ … ] },
|
|
399
464
|
"modelFiles?": { "<preset id>": "<the URL the app serves its .glb at>" }, "format?": "folder" | "glvideo",
|
|
400
465
|
"pack?": false, "smaller?": false, "progressId?": "<8–64 of A-Z a-z 0-9 ->" }
|
|
401
466
|
→ folder: { folder, index, packed: { path, bytes } | null, files, bytes, missing, leftOut, fields, smaller }
|
|
@@ -416,27 +481,32 @@ GET /api/moodboards/{board_id}/video-projects/{project_id}/glvideo?models=<JSON
|
|
|
416
481
|
project) or to a chat: the GreenLight Motion connector's `open_video` page reads it in the user's browser, and
|
|
417
482
|
`get_video` / `edit_video` change it there.
|
|
418
483
|
|
|
419
|
-
-
|
|
420
|
-
|
|
421
|
-
inside `project.json` with their layers.
|
|
484
|
+
- Each picture marked with `photoSlot` in the project (saved before the export) becomes an `image` field of the template
|
|
485
|
+
(its clip's `url` + `proxyUrl`); the marks travel inside `project.json` with their layers.
|
|
422
486
|
- `template.fields[]` = `{ key, label, type: text|color|image|number|select|toggle, targets: [{ itemId | clipId | (neither = project), path }] }`
|
|
423
487
|
— e.g. `{ "key": "headline", "label": "Headline", "type": "text", "targets": [{ "itemId": "<item id>", "path": "textContent" }] }`,
|
|
424
488
|
a shape colour `path: "shapeFillColor"`, the background `{ "path": "background.solidColor" }`. Omit `template` to keep
|
|
425
489
|
the project's saved one; `pack: true` (folder only) also writes one self-contained `.html` next to the folder (for a
|
|
426
490
|
Claude artifact: keep it under 16 MB). `smaller: true` puts WAV sounds in as MP3 and PNG pictures as WebP (transparency
|
|
427
491
|
kept) and points the exported project at them — the board keeps its originals; `smaller` in the answer is
|
|
428
|
-
`{ sounds, pictures, before, after }`.
|
|
492
|
+
`{ sounds, pictures, before, after }`. The endpoint has no top-level `title`: name the export through `template.title`.
|
|
493
|
+
A name already taken gets a number: `<title> 2.glvideo`, `<title> 2/`.
|
|
429
494
|
- The template is kept beside the project (`<board folder>/.motion/<project id>.template.json`), not in its `data`.
|
|
430
495
|
- `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.
|
|
432
|
-
(
|
|
496
|
+
has byte for byte; the template comes back too. The page's `greenlightdash://open?path=…` link does the same
|
|
497
|
+
(handled by the desktop app on the board that is open).
|
|
433
498
|
- `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
499
|
|
|
436
500
|
### Interface scenes (built-in animated scenes — "the UI kit")
|
|
437
501
|
|
|
438
|
-
The
|
|
502
|
+
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
503
|
|
|
440
504
|
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
505
|
|
|
442
|
-
The library is the app's own, at `$GL_MOTION_KIT_DIR` in GLAI
|
|
506
|
+
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):
|
|
507
|
+
|
|
508
|
+
```bash
|
|
509
|
+
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
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
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).
|