greenlight-motion 0.1.0 → 0.1.2
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 +44 -14
- package/THIRD_PARTY_LICENSES.md +2 -1
- package/cli/index.mjs +84 -3
- package/dist/bundle-inputs.json +21 -0
- package/dist/greenlight-motion-script.mjs +16749 -0
- package/dist/greenlight-motion.js +957 -681
- package/dist/greenlight-motion.mjs +59078 -55093
- package/dist/script-inputs.json +35 -0
- package/package.json +2 -1
- package/schema/capabilities.json +1 -1
- package/skill/SKILL.md +29 -21
- package/skill/references/scene-script.md +104 -0
- package/skill/references/video-3d.md +75 -6
- package/skill/references/video-camera.md +1 -0
- package/skill/references/video-design.md +65 -0
- package/skill/references/video-effects.md +6 -6
- package/skill/references/video-keyframes.md +1 -1
- package/skill/references/video-particles.md +5 -5
- package/skill/references/video-projects.md +42 -5
- package/skill/references/video-text-presets.md +47 -7
- package/skill/references/video-text.md +6 -4
|
@@ -93,7 +93,7 @@ Rules:
|
|
|
93
93
|
}
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
**`solver`** — the box the particles live in. Its transform *is* the layer's, so moving/rotating/scaling/parenting the item moves the whole volume; V1 simulates in local space (`space` is always `"local"`), so particles travel with it.
|
|
96
|
+
**`solver`** — the box the particles live in. Its transform *is* the layer's, so moving/rotating/scaling/parenting the item moves the whole volume; V1 simulates in local space (`space` is always `"local"`), so particles travel with it. The box is centred on the layer's position (`panX`/`panY`/`panZ` and the PRS keyframes) in the same world as every other layer and is filmed once by the project camera, so a solver given a Shape's exact transform fills that Shape's rect under any camera — pan, zoom, roll, tilt, dolly, keyed moves and a Blender picture-plane world thousands of px down a page alike (versions up to 15.0.21 drew an off-centre solver at twice its offset, often out of frame under a moving camera). A camera-locked channel keeps it in frame space. To snow onto a rect, size `bounds.size`/the emitter to the rect and put the layer at its centre.
|
|
97
97
|
|
|
98
98
|
| Field | Range | Meaning |
|
|
99
99
|
| --- | --- | --- |
|
|
@@ -116,7 +116,7 @@ Rules:
|
|
|
116
116
|
| `stir` / `detach` | `{strength 0–200, radius px}` / bool | v3: how hard emission stirs the fluid; `detach: true` makes emission deposit material only and hands the field's momentum to `forceEmitters`; `stir.strength: 0` then makes the emitter deposit nothing at all, which is how you get a layer whose only smoke comes from `kind: "smoke"` force emitters. |
|
|
117
117
|
| `position` / `size` | px vec3 | Placement and extent inside the box (`size.x` is the diameter for `sphere`/`cone`). |
|
|
118
118
|
| `rate` | 0–20000 /s | Continuous emission. |
|
|
119
|
-
| `bursts` | ≤8 × `{time, count}` | One-shot pops on top of the rate
|
|
119
|
+
| `bursts` | ≤8 × `{time, count}` | One-shot pops on top of the rate. This is how a shockwave / explosion / puff is built: a burst emits its whole count in one frame, `speed` throws the front outward (with `shape: "ring"` + `dirMode: "outward"` for a ring blast) and `forces.drag` is what makes it slam to a halt. Several entries = several pulses; ignored when `emit: "static"`. |
|
|
120
120
|
| `start` / `end` | s | Emission window inside the clip. |
|
|
121
121
|
| `lifetime` | `{value 0.05–600, random}` | Seconds, ± per particle. |
|
|
122
122
|
| `speed` | `{value, random}` | Launch speed in composition px/s. |
|
|
@@ -166,7 +166,7 @@ Distribution is part of the simulation, so changing it re-bakes (a `mode` of `ra
|
|
|
166
166
|
|
|
167
167
|
#### The fluid field (v3) — fluid and smoke physics, Particular-style
|
|
168
168
|
|
|
169
|
-
`forces.fluid` turns on a **simulated** force field: an incompressible velocity grid the size of the Solver Box that emission stirs and particles ride. Unlike turbulence (a formula in position and time) it has **memory** — a plume meets the eddies it left behind, smoke curls back on itself, ink mushrooms. It is stepped at 1/60 s with the particles and thrown away after the bake; only particle snapshots are cached. The fluid supplies **movement** — particles carried, swirled and lifted by a medium. It is not rendered itself: there is no volumetric or dye render mode, and particles are always drawn as particles. **Cost:** the grid steps 360× for a 6 s clip — `low` ≈ 4 ms/step, `medium` ≈ 10, `high` ≈ 30, `ultra`/`flat-hd` ≈ 80, `volume-hd` ≈ 250 — so a `flat-hd` preset bakes for ~30 s (in a worker, while the live GPU preview already shows the edit — see **Live preview** below
|
|
169
|
+
`forces.fluid` turns on a **simulated** force field: an incompressible velocity grid the size of the Solver Box that emission stirs and particles ride. Unlike turbulence (a formula in position and time) it has **memory** — a plume meets the eddies it left behind, smoke curls back on itself, ink mushrooms. It is stepped at 1/60 s with the particles and thrown away after the bake; only particle snapshots are cached. The fluid supplies **movement** — particles carried, swirled and lifted by a medium. It is not rendered itself: there is no volumetric or dye render mode, and particles are always drawn as particles. **Cost:** the grid steps 360× for a 6 s clip — `low` ≈ 4 ms/step, `medium` ≈ 10, `high` ≈ 30, `ultra`/`flat-hd` ≈ 80, `volume-hd` ≈ 250 — so a `flat-hd` preset bakes for ~30 s (in a worker, while the live GPU preview already shows the edit — see **Live preview** below).
|
|
170
170
|
|
|
171
171
|
**Live preview vs. the bake (10.4.37+).** In the desktop editor the whole solver — field and particles — also runs **live on WebGPU** (`frontend/src/sim/particles/gpuEngine.js`), so a change to any of these controls shows within a frame while the user edits; the bake above re-runs in the background after the edit and, once complete, is what the editor scrubs (any time is one read) and what **exports render**. Live is also the fallback-free path when the bake is not there yet; the bake is the fallback when live is not possible (Smoke Dye and Grow Points are bake-only) or when the live step cannot hold 60 fps on that machine. The bake itself is cancellable, debounced and streams in chunks, so even the fallback appears progressively. None of this changes the JSON: the bake is a pure function of the system and the live engine is only a preview of the same inputs. Route "smoke", "fluid", "ink in water", "cigarette smoke", "steam", "particles flowing like liquid" here. Ready-made presets: `smoke` (Rising Smoke), `cigarette`, `ink`, `ember`, `shockwave` (a ring blast every 1.5 s that punches the fluid). A `text-cage` preset id in older projects is just a label; the system it carries still bakes.
|
|
172
172
|
|
|
@@ -182,7 +182,7 @@ Distribution is part of the simulation, so changing it re-bakes (a `mode` of `ra
|
|
|
182
182
|
| `fluid.bounds` | `closed` \| `open` | Walls (a plume pools under the top) or outflow (it leaves the box). |
|
|
183
183
|
| `fluid.smoke` | `{buoyancy −200–600, weight 0–400, heat 0–100, cooling 0–4, dissipate 0–4}` | Smoke model only. Emission deposits hot dye; heat lifts, weight pulls. |
|
|
184
184
|
| `forces.draft` | `{strength 0–300, direction 0–360°, gust 0–1}` | A gusting wind on the field. Its variation is in time only, so it reads as weather, never as a pattern. |
|
|
185
|
-
| `forceEmitters[]` | ≤3 × `{id, kind jet\|vortex\|smoke\|burst, mode vortex\|orbit\|twist\|attract\|repel, lift, pulseStart, pulseEvery, position, radius, strength 0–300, push px/s, direction °, spin ccw\|cw, density 0–8, heat 0–100, motion hold\|orbit\|sweep, motionSpeed, motionRadius}` | Forces placed in the box, with no particles of their own. `kind: "jet"` is a **Blower** in the UI — a fan: everything inside `radius` is blended toward `push` px/s along `direction` (0° = right, 90° = up) at a rate set by `strength`; `kind: "vortex"` is a whirlpool (`spin`). With `forces.fluid` on they drive the field and the particles ride it (the emitter's own stir keeps working unless `emitters[0].detach`); with the field off blowers and vortices act on the particles directly. `kind: "smoke"` is a **source**, not a push: it injects material (`density` per second, at `heat`) into the field at its point, so a layer can have several plumes coming from places you choose rather than one from the emitter — it needs `forces.fluid.model: "smoke"`, and `strength: 0` makes it pure material lifted by buoyancy. `kind: "burst"` is a **pulse force**: a radial blast from its point at `pulseStart`, repeating every `pulseEvery` seconds (0 = once), throwing what is inside `radius` outward at `push` px/s — a shockwave with no particles of its own. A `vortex`-kind emitter takes any of
|
|
185
|
+
| `forceEmitters[]` | ≤3 × `{id, kind jet\|vortex\|smoke\|burst, mode vortex\|orbit\|twist\|attract\|repel, lift, pulseStart, pulseEvery, position, radius, strength 0–300, push px/s, direction °, spin ccw\|cw, density 0–8, heat 0–100, motion hold\|orbit\|sweep, motionSpeed, motionRadius}` | Forces placed in the box, with no particles of their own. `kind: "jet"` is a **Blower** in the UI — a fan: everything inside `radius` is blended toward `push` px/s along `direction` (0° = right, 90° = up) at a rate set by `strength`; `kind: "vortex"` is a whirlpool (`spin`). With `forces.fluid` on they drive the field and the particles ride it (the emitter's own stir keeps working unless `emitters[0].detach`); with the field off blowers and vortices act on the particles directly. `kind: "smoke"` is a **source**, not a push: it injects material (`density` per second, at `heat`) into the field at its point, so a layer can have several plumes coming from places you choose rather than one from the emitter — it needs `forces.fluid.model: "smoke"`, and `strength: 0` makes it pure material lifted by buoyancy. `kind: "burst"` is a **pulse force**: a radial blast from its point at `pulseStart`, repeating every `pulseEvery` seconds (0 = once), throwing what is inside `radius` outward at `push` px/s — a shockwave with no particles of its own. A `vortex`-kind emitter takes any of the five `forces.vortex` `mode`s, centred on itself. |
|
|
186
186
|
| `pathFollow` | `{enabled, attract 0–600, flow 0–600, proximity px, swirl 0–400, anchors[]}` | Particular's "move along path": attract toward the spline, flow along its tangent, braid around it, falling off past `proximity`. `anchors` are in the box's unit space; empty = the emitter's own `path`. |
|
|
187
187
|
| `collision` | `{source, faces outer\|inner, bounce 0–1, depth px, size px, position {x,y}}` | A layer's artwork (same `source` snapshot as a particle object) as a signed-distance collider extruded `depth` px. `outer` = obstacle (particles bounce, the flow routes around it); `inner` = container (emission is clipped to it, particles stay inside). A text source gives "particles inside letters". **`follow`** (default false, needs `fromItemId`) makes the collider track that layer's live ARTWORK and its position, scale and rotation every step — keyframes and parenting included — and turns `size`/`position`/`rotation` into the FALLBACK for a source that has been deleted. It also follows the layer's place on the TIMELINE: outside that clip's span, or while its channel is hidden, there is no collider at all (particles pass through and a container stops holding them). Without it the collider stands wherever `size`/`position` put it whatever the layer does, and those are filled in only at pick time. Two limits while following: a moving collider re-simulates every time its pose changes, and it steers the PARTICLES only — the fluid field's obstacle mask is a fixed grid over the Solver Box, so the flow passes straight through. **`releaseOutside`** (default false, container only) stops the container gathering particles that are already outside it — turn it on when the collider arrives part-way through a shot, or it inhales the cloud around it. **`open`** `{side none\|top\|bottom\|left\|right, spread 1–180°, at s, fade s}` (container only) lets particles OUT through one side at a chosen time — `at` is when it starts, `fade` how long it takes to open, `spread` how much of that wall becomes a doorway. It gates on the contact NORMAL, so `bottom` opens the floor of every stroke of a letter rather than cutting the shape in half, and what leaves stays out; sides are the FRAME's, so `bottom` is the bottom of the screen even under a rotating source. `side: 'none'` contributes nothing to the sim hash. Authoring note: the fixed collider's SDF is a 384x216 grid scaled from the box WIDTH, so keep `solver.bounds.size` near 16:9 or the field is distorted along the short axis. |
|
|
188
188
|
| `colliders[]` | up to **3** more, each the SAME shape as `collision` | The colliders past the first. `collision` stays the canonical FIRST one, so a project with a single collider is unchanged; four total. A particle stays OUTSIDE every obstacle and INSIDE every container, and emission is clipped to the INTERSECTION of the containers. Resolution is sequential rather than a joint solve, so two containers that do not overlap are unsatisfiable — visible on screen rather than averaged away. Only the first FIXED collider shapes the fluid field (one flow grid), and a system with more than one always previews from the bake. Because a `follow: true` collider stops existing outside its layer's span, two colliders on two spans give you DIFFERENT COLLISIONS OVER TIME with no keyframes. |
|
|
@@ -246,7 +246,7 @@ The other fluid presets differ from these only in numbers: *Cigarette* is Rising
|
|
|
246
246
|
| `colorOverLife` | ≤16 stops `{at 0–1, color}` | Colours are stored `#rrggbbaa` (`rgba()` is accepted and normalised). **Alpha always comes from this ramp**, whatever `colorMode` says — end stops must be transparent or particles pop in/out. |
|
|
247
247
|
| `colorMode` | `age` \| `source` \| `velocity` | Where RGB comes from. `source` needs a `layer` emitter. (v1's `colorFrom: 'ramp'\|'source'` migrates to `age`/`source`.) |
|
|
248
248
|
| `velocityRamp` / `velocityRange` | ramp / `{min, max}` px/s | The `velocity` gradient and the speeds that read its ends (`max` is forced above `min`). |
|
|
249
|
-
| `twinkle` / `stretch` / `opacity` | 0–1 / 0–4 / 0–1 | Per-particle flicker (a ~1 Hz opacity pulse);
|
|
249
|
+
| `twinkle` / `stretch` / `opacity` | 0–1 / 0–4 / 0–1 | Per-particle flicker (a ~1 Hz opacity pulse); how far a particle is smeared along its own velocity, 0 a dot and high values a streak; layer opacity. |
|
|
250
250
|
| `onKill` | `{fade 0–10 s, scale 0–1}` | How a particle **leaves**. Only possible because the clip is baked in advance: a particle knows a wall is coming and starts fading/shrinking before it goes. `scale` needs a non-zero `fade`. |
|
|
251
251
|
|
|
252
252
|
#### Emitting from a layer (`shape: "layer"`) — dissolve a picture into its own pixels
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
| You are making one version of a project for Variants (your copy, the scope, `ready`) | [video-variants.md](video-variants.md) |
|
|
30
30
|
| 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](../SKILL.md#ai-workflows-the-greenlight-mcp-server)); `essential` below |
|
|
31
31
|
|
|
32
|
-
In this file: Editing and verification · Video Projects (list, get, 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.
|
|
32
|
+
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.
|
|
33
33
|
|
|
34
34
|
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](../../motionfast-templates/SKILL.md).
|
|
35
35
|
|
|
@@ -41,6 +41,17 @@ Use `GET /api/video-editor/capabilities` for the installed build's effect and tr
|
|
|
41
41
|
4. GET again to verify the saved structure. For visible changes, use a composited still or a short full render from [video-rendering.md](video-rendering.md) to check the result: a successful save alone does not prove an effect or track renders.
|
|
42
42
|
5. Keep all media used by the project, including nested sequences and effect assets, even when it has no canvas card. Follow [media cleanup](media.md#clean-up-helper--intermediate-media-do-this-at-the-end-of-a-task).
|
|
43
43
|
|
|
44
|
+
## Text animation pace
|
|
45
|
+
|
|
46
|
+
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:
|
|
47
|
+
|
|
48
|
+
- **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`.
|
|
49
|
+
- **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`.
|
|
50
|
+
- **Layer entrances and exits** on a text layer: `transitionInDuration` / `transitionOutDuration` of 0.8 or more.
|
|
51
|
+
- **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.
|
|
52
|
+
|
|
53
|
+
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).
|
|
54
|
+
|
|
44
55
|
## Video Projects (per board)
|
|
45
56
|
|
|
46
57
|
A "video project" is a saved video-editor session inside a board.
|
|
@@ -48,11 +59,13 @@ A "video project" is a saved video-editor session inside a board.
|
|
|
48
59
|
### List video projects
|
|
49
60
|
|
|
50
61
|
```
|
|
51
|
-
GET /api/moodboards/{board_id}/video-projects[?data=active]
|
|
62
|
+
GET /api/moodboards/{board_id}/video-projects[?data=active|none]
|
|
52
63
|
→ { "projects": [{ "id", "name", "data", "updated_at", "summary", "preview", ... }], "active_project_id?" }
|
|
64
|
+
GET /api/video-projects/counts
|
|
65
|
+
→ { "counts": { "<board id>": <number of projects>, … } } (boards without projects are left out)
|
|
53
66
|
```
|
|
54
67
|
|
|
55
|
-
`?data=active` leaves out every payload except the open project's (`data: null`, `has_data: false`), so
|
|
68
|
+
`?data=active` leaves out every payload except the open project's (`data: null`, `has_data: false`) and `?data=none` leaves out all of them, so they are the cheap ways to survey a board: each project still carries `summary`, which answers "how long is it, how big, how busy" without downloading timelines. The single-project GET / POST / PUT answers carry both fields too. Both are derived and read-only — never send them back.
|
|
56
69
|
|
|
57
70
|
- `summary`: `{duration, width, height, layers, clips, markers, background, media}` — timeline length in seconds (speed curves approximated), canvas size, channels that hold items, item count, marker count, the solid background colour (or null), and the first picture on the timeline.
|
|
58
71
|
- `preview`: null, or any of `thumbnail` + `thumbnailVersion` (a ~480 px frame of the project as it was last left after changes, and the project version it shows) and `frames` + `framesDuration` + `framesAt` (one JPEG strip of five frames side by side, cut from the last full-project render at `framesAt[i] × framesDuration` seconds). The URLs live under the board's `.project-previews/`.
|
|
@@ -63,13 +76,24 @@ GET /api/moodboards/{board_id}/video-projects[?data=active]
|
|
|
63
76
|
GET /api/moodboards/{board_id}/video-projects/{project_id}
|
|
64
77
|
```
|
|
65
78
|
|
|
79
|
+
### Project card (what a canvas card shows)
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
GET /api/moodboards/{board_id}/video-projects/{project_id}/card
|
|
83
|
+
→ { "id", "board_id", "board_name", "name", "version", "updated_at", "summary", "preview" } 404 once removed
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The project without its payload, plus its board's name: what a `video-project` canvas card shows ([elements.md](elements.md#video-editor-project-cards-a-video-project-on-any-board)). Use it to check that a board/project pair exists before making a card.
|
|
87
|
+
|
|
66
88
|
### Create video project
|
|
67
89
|
|
|
68
90
|
```
|
|
69
91
|
POST /api/moodboards/{board_id}/video-projects
|
|
70
|
-
{ "name": "Cut 01", "data": { ... } }
|
|
92
|
+
{ "name": "Cut 01", "data": { ... }, "activate?": true }
|
|
71
93
|
```
|
|
72
94
|
|
|
95
|
+
A new project becomes the board's open one (the Video Editor opens it next). Send `"activate": false` when the project is for later, e.g. behind a canvas card, so the user's editor keeps the project it has.
|
|
96
|
+
|
|
73
97
|
### Update video project
|
|
74
98
|
|
|
75
99
|
```
|
|
@@ -210,6 +234,10 @@ If you need to build the editor state by hand instead of using `/from-clips`, th
|
|
|
210
234
|
|
|
211
235
|
**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.
|
|
212
236
|
|
|
237
|
+
**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.
|
|
238
|
+
|
|
239
|
+
**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.
|
|
240
|
+
|
|
213
241
|
**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.
|
|
214
242
|
|
|
215
243
|
**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`.
|
|
@@ -337,10 +365,19 @@ POST /api/moodboards/{board_id}/video-projects/{project_id}/export-light
|
|
|
337
365
|
"modelFiles?": { "<preset id>": "<the URL the app serves its .glb at>" }, "pack?": false }
|
|
338
366
|
→ { folder, index, packed: { path, bytes } | null, files, bytes, missing, leftOut, fields }
|
|
339
367
|
POST /api/moodboards/{board_id}/video-projects/import-light
|
|
340
|
-
{ "path": "/abs/folder | …/index.html | a one-file page.html", "activate?": true }
|
|
368
|
+
{ "path": "/abs/folder | …/index.html | a one-file page.html | a .glvideo file", "activate?": true }
|
|
341
369
|
→ the new project (like POST video-projects) + { copied }
|
|
370
|
+
GET /api/moodboards/{board_id}/video-projects/{project_id}/glvideo?models=<JSON { "<preset id>": "<the URL the app serves its .glb at>" }>
|
|
371
|
+
→ <name>.glvideo (answers once the whole file is written; `models` as in export-light's modelFiles — without it the
|
|
372
|
+
3D device presets are left out)
|
|
342
373
|
```
|
|
343
374
|
|
|
375
|
+
- A `.glvideo` is the same project folder in one stored zip: `glvideo.json` (`{format: "glvideo", version: 1, title}`),
|
|
376
|
+
`project.json`, `assets/media/<rel>`, `assets/hdr/` and `assets/models/` (the 3D device presets the project uses). It
|
|
377
|
+
is the file to hand a project to another GreenLight Dash (`import-light` with its path opens it there as a new
|
|
378
|
+
project) or to a chat: the GreenLight Motion connector's `open_video` page reads it in the user's browser, and
|
|
379
|
+
`get_video` / `edit_video` change it there.
|
|
380
|
+
|
|
344
381
|
- `template.fields[]` = `{ key, label, type: text|color|image|number|select|toggle, targets: [{ itemId | clipId | (neither = project), path }] }`
|
|
345
382
|
— e.g. `{ "key": "headline", "label": "Headline", "type": "text", "targets": [{ "itemId": "<item id>", "path": "textContent" }] }`,
|
|
346
383
|
a shape colour `path: "shapeFillColor"`, the background `{ "path": "background.solidColor" }`. Omit `template` to keep
|
|
@@ -34,7 +34,9 @@ works. Append the second recipe with fresh ids (next section).
|
|
|
34
34
|
Preferred presets: once the table has given the category, and the user has not named a preset, choose from these
|
|
35
35
|
first:
|
|
36
36
|
|
|
37
|
-
- Sweep Reveal: [Glide
|
|
37
|
+
- Sweep Reveal: [Glide Text](#glide-text) (the default text animation), [Ember Type](#ember-type).
|
|
38
|
+
[Glide Continuous](#glide-continuous) and [Glide Soft Stop](#glide-soft-stop) keep the word drifting for the whole
|
|
39
|
+
layer and never let it rest: use them only when the user asks for that.
|
|
38
40
|
- Emphasis: [Marker](#marker), [Outline Pass](#outline-pass).
|
|
39
41
|
- Loop: [Neon Flow](#neon-flow).
|
|
40
42
|
- Characters: [Glitch](#glitch).
|
|
@@ -77,7 +79,7 @@ Type on Shape has no preferred preset: pick by the shape the text needs.
|
|
|
77
79
|
|
|
78
80
|
<!-- PRESETS:BEGIN (generated by tests/text-animators/skill_presets.mjs from presets.js — edit there) -->
|
|
79
81
|
|
|
80
|
-
|
|
82
|
+
48 recipes in 6 categories. **Lengths** to multiply by `textFontSize / 100`: `blur` `amount`; `box` `padX`, `padY`, `radius`; `glow` `size`; `position` `x`, `y`; `rotate3d` `perspective`; `stroke` `width`.
|
|
81
83
|
|
|
82
84
|
**A fade-out exit for an `in` recipe**, ending with the layer: append this animator with unused ids. Set its `basedOn` to the recipe's first animator's, and `stagger` to 0.015 for letters, 0.04 for words or 0.08 for lines.
|
|
83
85
|
|
|
@@ -89,8 +91,9 @@ Type on Shape has no preferred preset: pick by the shape the text needs.
|
|
|
89
91
|
|
|
90
92
|
| Recipe | Category | What it does | Unit | Plays |
|
|
91
93
|
|---|---|---|---|---|
|
|
92
|
-
| [Glide
|
|
93
|
-
| [Glide
|
|
94
|
+
| [Glide Text](#glide-text) | Sweep Reveal | Letters type in as the word glides left under a chasing blue band and settles, then it slides off to the left | letters | in, the first 1.3 s, then out at the end |
|
|
95
|
+
| [Glide Continuous](#glide-continuous) | Sweep Reveal | Letters type in under a chasing blue band as the word keeps gliding through the whole layer, slow in the middle, then slides off to the left | letters | in, the first 1.13 s (Glide in across the whole layer), then out at the end |
|
|
96
|
+
| [Glide Soft Stop](#glide-soft-stop) | Sweep Reveal | The same typing and band, but the word glides through the whole layer and all but stops in the middle, then slides off to the left | letters | in, the first 1.13 s (Glide in across the whole layer), then out at the end |
|
|
94
97
|
| [Ember Type](#ember-type) | Sweep Reveal | Letters type in falling softly; a warm band trails behind the typing | letters | in, the first 3 s |
|
|
95
98
|
| [Molten Edge](#molten-edge) | Sweep Reveal | Letters land glowing hot at the typing edge and cool down behind it | letters | in, the first 2 s |
|
|
96
99
|
| [Focus Type](#focus-type) | Sweep Reveal | A long soft wave of letters falls out of a blur under a cool gradient | letters | in, the first 2.9 s |
|
|
@@ -139,9 +142,46 @@ Type on Shape has no preferred preset: pick by the shape the text needs.
|
|
|
139
142
|
|
|
140
143
|
## Sweep Reveal
|
|
141
144
|
|
|
142
|
-
### Glide
|
|
145
|
+
### Glide Text
|
|
143
146
|
|
|
144
|
-
Letters type in
|
|
147
|
+
Letters type in as the word glides left under a chasing blue band and settles, then it slides off to the left. Plays: in, the first 1.3 s, then out at the end. Designed for a `#05070a` background, left-aligned. Written for text like “External Storage”.
|
|
148
|
+
|
|
149
|
+
```jsonc
|
|
150
|
+
{
|
|
151
|
+
"textAnimators": [
|
|
152
|
+
{ "aid": "a1", "name": "Type",
|
|
153
|
+
"range": {"basedOn": "charsNoSpace", "smooth": 0},
|
|
154
|
+
"effects": [{"pid": "p1", "type": "opacity", "opacity": 0}] },
|
|
155
|
+
{ "aid": "a2", "name": "Blue band",
|
|
156
|
+
"range": {"basedOn": "charsNoSpace", "start": 0, "end": 100, "shape": "smooth", "easeHigh": 40},
|
|
157
|
+
"effects": [{"pid": "p2", "type": "fill", "color": "#3e59ad"}] },
|
|
158
|
+
{ "aid": "a3", "name": "Glide in",
|
|
159
|
+
"range": {"basedOn": "charsNoSpace", "around": "all"},
|
|
160
|
+
"effects": [{"pid": "p3", "type": "position", "x": 250, "y": 0}] },
|
|
161
|
+
{ "aid": "a4", "name": "Glide out",
|
|
162
|
+
"range": {"basedOn": "charsNoSpace", "drive": "time", "animate": "out", "around": "all", "stagger": 0, "duration": 0.5, "curve": "Power2 In"},
|
|
163
|
+
"effects": [{"pid": "p4", "type": "position", "x": -150, "y": 0}, {"pid": "p5", "type": "opacity", "opacity": 0}] }
|
|
164
|
+
],
|
|
165
|
+
"propertyKeyframes": {
|
|
166
|
+
"txa:a1:start": [
|
|
167
|
+
{ "position": 0, "value": "0", "easing": "AE In" }, // 0 s
|
|
168
|
+
{ "position": 20, "value": "100", "easing": "Smooth" } // 1 s
|
|
169
|
+
],
|
|
170
|
+
"txa:a2:offset": [
|
|
171
|
+
{ "position": 6, "value": "-58", "easing": "Power1 Out" }, // 0.3 s
|
|
172
|
+
{ "position": 26, "value": "101", "easing": "Smooth" } // 1.3 s
|
|
173
|
+
],
|
|
174
|
+
"txa:a3:amount": [
|
|
175
|
+
{ "position": 0, "value": "100", "easing": "AE In" }, // 0 s
|
|
176
|
+
{ "position": 20, "value": "0", "easing": "Smooth" } // 1 s
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Glide Continuous
|
|
183
|
+
|
|
184
|
+
Letters type in under a chasing blue band as the word keeps gliding through the whole layer, slow in the middle, then slides off to the left. Plays: in, the first 1.13 s (Glide in across the whole layer), then out at the end. Designed for a `#05070a` background, left-aligned. Written for text like “External Storage”.
|
|
145
185
|
|
|
146
186
|
```jsonc
|
|
147
187
|
{
|
|
@@ -178,7 +218,7 @@ Letters type in under a chasing blue band as the word glides through, slow in th
|
|
|
178
218
|
|
|
179
219
|
### Glide Soft Stop
|
|
180
220
|
|
|
181
|
-
The same typing and band, but the word all but stops in the middle
|
|
221
|
+
The same typing and band, but the word glides through the whole layer and all but stops in the middle, then slides off to the left. Plays: in, the first 1.13 s (Glide in across the whole layer), then out at the end. Designed for a `#05070a` background, left-aligned. Written for text like “External Storage”.
|
|
182
222
|
|
|
183
223
|
```jsonc
|
|
184
224
|
{
|
|
@@ -131,6 +131,8 @@ Orders {{{COUNTER:0-2,480; style=roll; duration=2.5; easing=expo_out}}} in {{{TI
|
|
|
131
131
|
- Energetic text (a title, hook, promo or social line, anything punchy, bouncy or kinetic): [video-text-kinetic.md](video-text-kinetic.md) — 21 in-and-out animations (Swing Pop and its family).
|
|
132
132
|
- Everything else: [video-text-presets.md](video-text-presets.md) — 46 recipes. Sweep Reveal (typing under a colour band), Type on Shape (text typed onto a pill, card or bar that grows with it), Emphasis (one highlight pass), Loop (neon, rainbow, float, flicker), Characters (decode, binary, glitch) and Voice (caption karaoke on `textWordTimings`).
|
|
133
133
|
|
|
134
|
+
**Pace:** an entrance, an exit or a one-time pass takes at least 0.8–1 s by default, from the first unit starting to the last one settling. On Time that span is `(units − 1) × stagger + duration`. Go faster only when the edit needs it. The rule and its exceptions are in [video-projects.md](video-projects.md#text-animation-pace).
|
|
135
|
+
|
|
134
136
|
The After Effects text-animator model on a text layer (stored as `textAnimators`). `item.textAnimators` is a list of animators; each one **selects** units of the text with a range (plus an optional wiggle) and applies its **effects** to them. Every effect value is what a unit gets at 100 % selection; the unit's selection amount (−1…1) scales it. What moves the selection is the range's `drive`: `range` (default) = Start / End / Offset, animated with `txa:` keyframe tracks or with `flow` for an endless keyframe-free travel; `time` = every unit takes its turn in seconds (a stagger, with In / Out and a highlight clock); `voice` = every unit takes its turn when it is spoken (captions). Renders identically in preview, `/render-onserver` and stills.
|
|
135
137
|
|
|
136
138
|
`textAnimators` is the only text animation. Never write `textSplit*`, `splitIn*`, `splitOut*`, `textHighlight*` or `textCaret*` fields, or `textRevealProgress` / `textHighlightColor` tracks: the app ignores them. A stored project that still carries them is converted into `textAnimators` when the app loads it, so a `GET` of a project nobody has opened since may still show them.
|
|
@@ -258,9 +260,9 @@ Worked examples (spread onto a text item; durations assume a 4 s layer):
|
|
|
258
260
|
|
|
259
261
|
// Fade Up (Time): words rise in 0.06 s apart, and leave upward at the end.
|
|
260
262
|
"textAnimators": [
|
|
261
|
-
{ "aid": "a1", "name": "In", "range": { "drive": "time", "animate": "in", "basedOn": "words", "stagger": 0.06, "duration": 0.
|
|
263
|
+
{ "aid": "a1", "name": "In", "range": { "drive": "time", "animate": "in", "basedOn": "words", "stagger": 0.06, "duration": 0.8, "curve": "Power2 Smooth" },
|
|
262
264
|
"effects": [ { "pid": "p1", "type": "position", "x": 0, "y": 50 }, { "pid": "p2", "type": "opacity", "opacity": 0 } ] },
|
|
263
|
-
{ "aid": "a2", "name": "Out", "range": { "drive": "time", "animate": "out", "basedOn": "words", "stagger": 0.06, "duration": 0.
|
|
265
|
+
{ "aid": "a2", "name": "Out", "range": { "drive": "time", "animate": "out", "basedOn": "words", "stagger": 0.06, "duration": 0.7, "curve": "Power2 Smooth" },
|
|
264
266
|
"effects": [ { "pid": "p3", "type": "position", "x": 0, "y": -50 }, { "pid": "p4", "type": "opacity", "opacity": 0 } ] }]
|
|
265
267
|
|
|
266
268
|
// Energetic in and out (Swing Pop and 20 more, each two Time animators): video-text-kinetic.md.
|
|
@@ -320,9 +322,9 @@ PUT /api/moodboards/{board_id}/video-projects/{project_id}
|
|
|
320
322
|
"textFontWeight": 800, "textAlign": "center",
|
|
321
323
|
"panX": 0, "panY": 35,
|
|
322
324
|
"textAnimators": [
|
|
323
|
-
{ "aid": "a1", "name": "In", "range": { "drive": "time", "animate": "in", "basedOn": "words", "stagger": 0.06, "duration": 0.
|
|
325
|
+
{ "aid": "a1", "name": "In", "range": { "drive": "time", "animate": "in", "basedOn": "words", "stagger": 0.06, "duration": 0.8, "curve": "Power2 Smooth" },
|
|
324
326
|
"effects": [ { "pid": "p1", "type": "position", "x": 0, "y": 50 }, { "pid": "p2", "type": "opacity", "opacity": 0 } ] },
|
|
325
|
-
{ "aid": "a2", "name": "Out", "range": { "drive": "time", "animate": "out", "basedOn": "words", "stagger": 0.06, "duration": 0.
|
|
327
|
+
{ "aid": "a2", "name": "Out", "range": { "drive": "time", "animate": "out", "basedOn": "words", "stagger": 0.06, "duration": 0.7, "curve": "Power2 Smooth" },
|
|
326
328
|
"effects": [ { "pid": "p3", "type": "position", "x": 0, "y": -50 }, { "pid": "p4", "type": "opacity", "opacity": 0 } ] }
|
|
327
329
|
]
|
|
328
330
|
}
|