greenlight-motion 0.3.4 → 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 +42670 -40592
- 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
|
@@ -10,7 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
## Contents
|
|
12
12
|
|
|
13
|
-
- [Particle layers (`mediaType: "particles"`) — a simulated Solver Box filmed by the project camera](
|
|
13
|
+
- [Particle layers (`mediaType: "particles"`) — a simulated Solver Box filmed by the project camera](#particle-layers-mediatype-particles--a-simulated-solver-box-filmed-by-the-project-camera): engines, rules, the example, `solver`, `emitters[0]`, `forces`
|
|
14
|
+
- [Emission distribution](#emission-distribution-emitters0distribution)
|
|
15
|
+
- [The fluid field — fluid and smoke physics](#the-fluid-field-v3--fluid-and-smoke-physics-particular-style), with the Rising Smoke and Shockwave starters and `render`
|
|
16
|
+
- [Emitting from a layer — dissolve a picture into its own pixels](#emitting-from-a-layer-shape-layer--dissolve-a-picture-into-its-own-pixels)
|
|
17
|
+
- [Particle Activation — a sweep instead of a pop](#particle-activation-activation--a-sweep-instead-of-a-pop)
|
|
18
|
+
- [Particle objects — stamp layers instead of sprites](#particle-objects-objects--stamp-layers-instead-of-sprites)
|
|
19
|
+
- [Lines — trails and ready-made lines](#lines-rendermode-lines--trails-and-ready-made-lines)
|
|
20
|
+
- [Particles as 3D objects in a world Scene](#particles-as-3d-objects-in-a-world-scene-particlelook-3d)
|
|
21
|
+
- [What re-bakes and what is free](#what-re-bakes-and-what-is-free)
|
|
14
22
|
|
|
15
23
|
### Particle layers (`mediaType: "particles"`) — a simulated Solver Box filmed by the project camera
|
|
16
24
|
|
|
@@ -20,6 +28,7 @@ Two engines run these, chosen automatically per layer and never by the author:
|
|
|
20
28
|
|
|
21
29
|
- **Baked (Tier 1)** at or below 50,000, and at any count for a system using a
|
|
22
30
|
feature the GPU engine does not implement — Grow Points, particle objects,
|
|
31
|
+
lines, a `rows` emitter, Return (`forces.return.strength` above 0), packing,
|
|
23
32
|
colour taken at birth from a video source, collision or a container, Layer
|
|
24
33
|
Velocity, force emitters, Path Follow, or `solver.engine: "baked"`. Off-thread,
|
|
25
34
|
cached, scrubs backwards for free.
|
|
@@ -27,10 +36,11 @@ Two engines run these, chosen automatically per layer and never by the author:
|
|
|
27
36
|
simulates and draws entirely on the graphics card and is **never baked** — so
|
|
28
37
|
`maxParticles` above the bake's ceiling is a real number of particles, not a
|
|
29
38
|
clamped one. It re-simulates on a backward scrub rather than reading a cache.
|
|
39
|
+
Without WebGPU such a system runs baked, clamped to 100,000 particles.
|
|
30
40
|
|
|
31
41
|
The count is a **ceiling, not a request**: a system emitting 260/s with a 5 s
|
|
32
42
|
lifetime holds about 1,300 at once whatever the ceiling says, and the baked
|
|
33
|
-
solver
|
|
43
|
+
solver sizes itself to what the emitter can actually reach. Raising the
|
|
34
44
|
number "to be safe" costs nothing. Use it for dust / snow / sparks / bokeh ambience, and for **dissolving an image, text or shape layer into its own pixels**.
|
|
35
45
|
|
|
36
46
|
**Route "add falling snow", "dust motes", "sparks", "make the logo dissolve/disintegrate into particles" here.**
|
|
@@ -39,14 +49,14 @@ Rules:
|
|
|
39
49
|
|
|
40
50
|
- Clip is synthetic (`"url": ""`), default duration 6 s. The system lives on the **item** as `particleSystem`; the clip carries nothing but identity.
|
|
41
51
|
- Put it on its **own free channel** (`"unsnapped": true`) and place it with `freeStart`. It is **not** a singleton (a project may hold several) and its channel has no `kind` — ordinary z-order applies.
|
|
42
|
-
- **Only `emitters[0]` is simulated.** The array is capped at 8 for future use; anything after the first is ignored by the solver
|
|
43
|
-
- **`preset` is metadata, not a reference.** Writing `"preset": "snow"` does **not** produce snow — the app materialises a complete system when
|
|
44
|
-
- Everything is passed through a bounded
|
|
45
|
-
- **Write `"schemaVersion": 4`.** A system with no `schemaVersion` is
|
|
52
|
+
- **Only `emitters[0]` is simulated.** The array is capped at 8 for future use; anything after the first is ignored by the solver.
|
|
53
|
+
- **`preset` is metadata, not a reference.** Writing `"preset": "snow"` does **not** produce snow — the app materialises a complete system only when a preset is applied in the editor. Author the actual values (start from the example and the starters below).
|
|
54
|
+
- Everything is passed through a bounded sanitizer on load: missing fields fall back to the Dust defaults, out-of-range numbers clamp, unknown enum values revert. A partial system is therefore safe to write, but it will be Dust plus your overrides — not the preset you named.
|
|
55
|
+
- **Write `"schemaVersion": 4`.** A system with no `schemaVersion` is read as version 1 and keeps `forces.turbulence.pattern: "smooth"`, not the current `"billow"` default. The optional blocks (`forces.fluid`, `forces.draft`, `forceEmitters`, `pathFollow`, `collision`, `proximity`) default to off, so leaving one out is the same as turning it off. `packing` is the same: while `packing.enabled` is false it contributes nothing to the simulation identity and `render.size` stays a look-only field.
|
|
46
56
|
- **Solver values are NOT keyframable.** The item's own Position/Rotation/Scale keyframes move the whole box, but there is no `pt:`/`ptc:` property path for wind, rate, turbulence or anything else in the system — those hold one value for the clip. If asked to "animate the wind", say so rather than emitting a track that will be dropped.
|
|
47
|
-
- **Deterministic bake.** The whole clip is simulated once at a fixed 1/60 s step from `seed` and sampled by absolute clip-local time, so preview, Download, a **Time range** render and `/render-onserver` all agree, and export motion blur samples real motion.
|
|
48
|
-
- Rendered by **`/render-onserver`** and the desktop render (the export waits for the bake before frame one). The fast `/render` ignores particle layers like every other non-trivial type.
|
|
49
|
-
- `quality.previewFraction`
|
|
57
|
+
- **Deterministic bake (baked tier).** The whole clip is simulated once at a fixed 1/60 s step from `seed` and sampled by absolute clip-local time, so preview, Download, a **Time range** render and `/render-onserver` all agree, and export motion blur samples real motion.
|
|
58
|
+
- Rendered by **`/render-onserver`** and the desktop render (for a baked-tier system the export waits for the bake before frame one). The fast `/render` ignores particle layers like every other non-trivial type.
|
|
59
|
+
- `quality.previewFraction` draws a stable seeded subset while editing — but it is stored on the item and is read at render time too, so leave it at `1` unless the user explicitly wants a thinner system.
|
|
50
60
|
|
|
51
61
|
```json
|
|
52
62
|
{
|
|
@@ -95,7 +105,7 @@ Rules:
|
|
|
95
105
|
}
|
|
96
106
|
```
|
|
97
107
|
|
|
98
|
-
**`solver`** — the box the particles live in. Its transform *is* the layer's, so moving/rotating/scaling/parenting the item moves the whole volume;
|
|
108
|
+
**`solver`** — the box the particles live in. Its transform *is* the layer's, so moving/rotating/scaling/parenting the item moves the whole volume; it 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. 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.
|
|
99
109
|
|
|
100
110
|
| Field | Range | Meaning |
|
|
101
111
|
| --- | --- | --- |
|
|
@@ -103,19 +113,19 @@ Rules:
|
|
|
103
113
|
| `preRoll` | 0–30 s | Simulated before the clip starts so the box is populated at frame 0. Ambient looks want ~one full lifetime; a dissolve wants `0`. |
|
|
104
114
|
| `bounds.size` | px vec3 | Box width/height/depth, in composition pixels. |
|
|
105
115
|
| `bounds.behavior` | `kill` \| `wrap` \| `bounce` | `wrap` is a teleport (never interpolated across); `bounce` reflects keeping ~60% of speed and stays continuous. |
|
|
106
|
-
| `cacheHz` | fixed `30` | Snapshot rate.
|
|
107
|
-
| `engine` | `auto` (default) · `live` · `baked` | How the EDITOR previews the system — not a simulation input
|
|
116
|
+
| `cacheHz` | fixed `30` | Snapshot rate. Always 30, whatever is written. |
|
|
117
|
+
| `engine` | `auto` (default) · `live` · `baked` | How the EDITOR previews the system — not a simulation input. `auto` reads the bake whenever it is complete for the current settings (smooth scrubbing) and draws the solver live on WebGPU in the gap after an edit, while the bake re-runs in the background — falling back to the bake outright when the live step cannot hold 60 fps; `live` always simulates live; `baked` always shows the bake, and also keeps a system above 50,000 on the baked solver instead of the GPU engine. On the baked tier, exports and `/render-onserver` always use the bake. Omit it. |
|
|
108
118
|
|
|
109
119
|
**`emitters[0]`** — where particles are born.
|
|
110
120
|
|
|
111
121
|
| Field | Range | Meaning |
|
|
112
122
|
| --- | --- | --- |
|
|
113
123
|
| `emit` | `continuous` \| `static` | `static` fills the system to the cap once and never emits again (rate and bursts ignored) — the right mode for a shatter/dissolve or anything Particle Activation releases. It fires at clip time `max(0, start)`, AFTER the pre-roll, so nothing it emits has a history at frame 0: for a trail that already exists at frame 0 (a comet), use `continuous` with `maxParticles` as the cap, which emits during the pre-roll. |
|
|
114
|
-
| `shape` | `point` \| `box` \| `sphere` \| `cone` \| `ring` \| `path` \| `block` \| `layer` \| `rows` | `layer` fires from a picture's opaque pixels — see below. With `render.colorMode: "source"` and a VIDEO source, `render.colorSourceTime` decides when each colour is read: `"live"` (default) re-reads the frame playing now, so the cloud is the moving picture; `"birth"` keeps the colour each particle was emitted with and lets the video move on without it. Either way the emission SITES stay from the one snapshot they were picked in, so nothing re-simulates and neither value is in the sim hash.
|
|
115
|
-
| `dirMode` | `cone` \| `outward` \| `tangent` |
|
|
124
|
+
| `shape` | `point` \| `box` \| `sphere` \| `cone` \| `ring` \| `path` \| `block` \| `layer` \| `rows` | `layer` fires from a picture's opaque pixels — see below. With `render.colorMode: "source"` and a VIDEO source, `render.colorSourceTime` decides when each colour is read: `"live"` (default) re-reads the frame playing now, so the cloud is the moving picture; `"birth"` keeps the colour each particle was emitted with and lets the video move on without it. Either way the emission SITES stay from the one snapshot they were picked in, so nothing re-simulates and neither value is in the sim hash. `ring` is an annulus in the comp plane (`ring: {radius, thickness}` px), `path` births evenly along a spline (`path.anchors` in the emitter's unit space, −0.5..0.5), `block` is a filled slab — the "layer of particles" source (pair it with `emit: "static"`). `rows` is a grid of **ready-made lines** — see [Lines](#lines-rendermode-lines--trails-and-ready-made-lines). |
|
|
125
|
+
| `dirMode` | `cone` \| `outward` \| `tangent` | `ring`/`path` only: launch along `direction`+`spread`, away from the ring / normal to the path, or along it. |
|
|
116
126
|
| `distribution` | `{mode, jitter, order, sites, rings, clusters, tightness, bias, thickness, sequence, relaxIters}` | **How spawn sites are SPREAD inside the emitter's shape** — orthogonal to the geometry, so `box`, `sphere`, `cone`, `ring`, `path` and `block` all get every mode (`layer` and `point` do not use it; see below). Omit the block (or `mode: "random"`) for the emitter's original three independent draws; that is the default and is byte-identical to a system authored without it. See the table below. |
|
|
117
|
-
| `variance` | 0–1 |
|
|
118
|
-
| `stir` / `detach` | `{strength 0–200, radius px}` / bool |
|
|
127
|
+
| `variance` | 0–1 | With the fluid on: wobble of emission direction/strength/heat on smooth time noise. `0` is a perfectly steady source, which reads as a uniform carpet of curls; ~0.35 gives billows at every scale. |
|
|
128
|
+
| `stir` / `detach` | `{strength 0–200, radius px}` / bool | 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. |
|
|
119
129
|
| `position` / `size` | px vec3 | Placement and extent inside the box (`size.x` is the diameter for `sphere`/`cone`). |
|
|
120
130
|
| `rate` | 0–20000 /s | Continuous emission. |
|
|
121
131
|
| `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"`. |
|
|
@@ -168,9 +178,9 @@ Distribution is part of the simulation, so changing it re-bakes (a `mode` of `ra
|
|
|
168
178
|
|
|
169
179
|
#### The fluid field (v3) — fluid and smoke physics, Particular-style
|
|
170
180
|
|
|
171
|
-
`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).
|
|
181
|
+
`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 (for volumetric smoke and fire inside a 3D world, see world3d.md (GreenLight Dash only)). **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).
|
|
172
182
|
|
|
173
|
-
**Live preview vs. the bake
|
|
183
|
+
**Live preview vs. the bake.** In the desktop editor the whole solver — field and particles — also runs **live on WebGPU**, 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 (Grow Points and the other baked-only features above) 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 when it is a layer of particles; volumetric smoke, fire or an explosion inside a 3D world is world3d.md (GreenLight Dash only). 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 is only a label; the system it carries bakes as written.
|
|
174
184
|
|
|
175
185
|
| Field | Range | Meaning |
|
|
176
186
|
| --- | --- | --- |
|
|
@@ -180,11 +190,11 @@ Distribution is part of the simulation, so changing it re-bakes (a `mode` of `ra
|
|
|
180
190
|
| `fluid.viscosity` / `.swirl` / `.swirlSize` | 0–100 / 0–200 / 1–6 cells | Thickness of the medium; vorticity confinement (feeds the small eddies back); the eddy size confinement reinforces. |
|
|
181
191
|
| `fluid.fidelity` | `low`\|`medium`\|`high`\|`ultra`\|`flat-hd`\|`volume-hd` | Grid resolution (48×27×12 … 192×108×48 at 16:9; the in-plane cell count is kept and laid out at the box's aspect, so a portrait box gets a tall grid, not a stretched one). `flat-hd` spends the budget in-plane for thin columns. |
|
|
182
192
|
| `fluid.pressureSteps` / `.fade` / `.sharp` | 4–60 / 0–1 / bool | Jacobi iterations; how fast the field calms; MacCormack advection (livelier, more detail). |
|
|
183
|
-
| `fluid.reach` | `box` (default) \| `sources` | Who rides the flow. `box` = every particle in the Solver Box (the field is the whole medium). `sources` = only particles inside a force emitter's `radius`, fading out with distance — the source as a LOCAL force, the rest of the layer keeps its own motion. Needs `emitters[0].detach: true` (
|
|
193
|
+
| `fluid.reach` | `box` (default) \| `sources` | Who rides the flow. `box` = every particle in the Solver Box (the field is the whole medium). `sources` = only particles inside a force emitter's `radius`, fading out with distance — the source as a LOCAL force, the rest of the layer keeps its own motion. Needs `emitters[0].detach: true` (set it together with `fluid.reach: "sources"`) and at least one entry in `forceEmitters`; ignored otherwise. |
|
|
184
194
|
| `fluid.bounds` | `closed` \| `open` | Walls (a plume pools under the top) or outflow (it leaves the box). |
|
|
185
195
|
| `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. |
|
|
186
196
|
| `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. |
|
|
187
|
-
| `forceEmitters[]` | ≤3 × `{id, kind jet\|vortex\|smoke\|burst, mode vortex\|orbit\|twist\|attract\|repel, lift, pulseStart, pulseEvery, position, radius, strength 0–
|
|
197
|
+
| `forceEmitters[]` | ≤3 × `{id, kind jet\|vortex\|smoke\|burst\|layer, mode vortex\|orbit\|twist\|attract\|repel, lift, pulseStart, pulseEvery, position, radius, strength 0–2000, 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** — 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. `kind: "layer"` is **Layer Velocity** (set by the editor; it keeps the system on the baked solver). |
|
|
188
198
|
| `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`. |
|
|
189
199
|
| `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. |
|
|
190
200
|
| `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. |
|
|
@@ -192,18 +202,17 @@ Distribution is part of the simulation, so changing it re-bakes (a `mode` of `ra
|
|
|
192
202
|
| `proximity` | `{enabled, sources[], measure shape\|edge\|inside\|centre\|x\|y, range px, falloff px, shape smooth\|linear\|sharp, combine max\|add\|average\|min, depth px, invert, fx{}, colorMode solid\|ramp, color, colorRamp[]}` | **Another layer's SHAPE drives this system.** Where the Grid Builder's Proximity measures distance to a layer's CENTRE, this measures distance to its SILHOUETTE, so a word or a logo drives the cloud along its own outline. Each entry in `sources` (≤4) is `{id, source, sourceName, fromItemId, follow, size px, position {x,y}, rotation, pickScale}` — a `follow: true` source also stops acting outside its layer's SPAN on the timeline (visibility is not read: hiding a donor layer is part of the dissolve recipe). With `packing.enabled`, `fx.size` additionally becomes a SIMULATION input — it is the contact radius — and re-bakes; without packing it stays drawn-only and free — `source` is the same artwork snapshot a particle object carries, and with `follow: true` the position, in-plane rotation and scale come live off `fromItemId` every frame (keyframes, motion paths and parenting included). `measure: "shape"` is the one to start from; `"inside"` plus `invert: true` is the core-only look. `depth: 0` keeps the field flat (z ignored); above 0 it is a slab of that half-thickness. |
|
|
193
203
|
| `proximity.fx` | `{size, opacity, color, spin, bright, stretch, twinkle, attract, flow, orbit, drag, speed, turb, freeze, life}` | What the field drives, as amounts (percentages except `attract`/`flow`/`orbit` in px/s² and `spin` in degrees). **The first seven are LOOK** — read at draw time, so they never re-bake. **The last eight are SIMULATION** — forces, so setting one re-bakes the clip. `attract` and `flow` push along the field's true surface normal (perpendicular to a letterform's edge, not radial from its centre); `freeze: 100` stops particles dead where the field is full, which paints the silhouette out of a passing cloud. Tune `falloff` against the artwork's THINNEST feature: a 300 px band around a word with 55 px strokes is a blob that happens to be word-shaped. Not read by the WebGPU runtime, so keep `solver.maxParticles` under 50,000 for it to have any effect. |
|
|
194
204
|
| **Animating the effector** | `propertyKeyframes: { "px:<param>": [...] }` on the PARTICLE ITEM | `range`, `falloff`, `depth` and every key present in `proximity.fx` take an ordinary numeric track, so a Range that opens over a shot or an Attract that lets go on a beat is authored with the normal keyframe machinery (easings, HOLD, the graph editor, `propertyKeyframeFx`). The stored value is the track's base. **A track on a LOOK amount never re-bakes** — it is read at draw time — while one on a SIM amount, or on `range`/`falloff`/`depth` while a force is driven, is a simulation input and re-bakes like editing that number statically does. An amount stored at 0 and keyed away from 0 DOES reach the solver. The enums (`measure`, `shape`, `combine`), `invert`, `colorMode`, `color` and `colorRamp` are deliberately not animatable; a source's pose is not either — set `follow: true` and keyframe the SOURCE LAYER, which is the whole design. |
|
|
195
|
-
| `render.dye` | `{mode off\|card, res 0–2, gain, contrast, edge, fade, useRamp}` | **The smoke itself**, rendered from the field's density instead of sprites. `card` is a 2D density field on the box's mid-plane (480×270 / 640×360 / 960×540), baked with the cache (~24 / 42 / 94 MB per 6 s) and drawn under the sprites with a "silk" edge term — the cigarette-smoke look. `mode`, `res` and `fade` re-bake; the rest is free. `volume` is reserved (needs WebGPU; not in the desktop editor yet). |
|
|
196
205
|
|
|
197
206
|
Physics that must hold when authoring by hand: with a `text` container, keep `fluid.force` low (≤ 20) and `collision.bounce` high — inside channels as thin as letter strokes a strongly coupled crossing flow presses everything against one wall; give the emitter its own `speed` instead. `open` bounds are safe (Dirichlet pressure + sponge + CFL clamp are built in).
|
|
198
207
|
|
|
199
|
-
**Starters
|
|
208
|
+
**Starters** — the overrides each preset applies on top of the defaults, for a 1920×1080 composition (scale the px values with the frame). Everything not listed keeps its default (`forces.fluid.model` defaults to `off`, so the `fluid` block is what switches the physics on).
|
|
200
209
|
|
|
201
210
|
*Rising Smoke* (`preset: "smoke"`): a buoyant plume that meets its own eddies.
|
|
202
211
|
|
|
203
212
|
```json
|
|
204
213
|
"particleSystem": {
|
|
205
214
|
"schemaVersion": 4, "preset": "smoke", "seed": 184729,
|
|
206
|
-
"solver": { "maxParticles": 4000, "preRoll":
|
|
215
|
+
"solver": { "maxParticles": 4000, "preRoll": 1.5, "bounds": { "size": { "x": 1920, "y": 1080, "z": 480 }, "behavior": "kill" } },
|
|
207
216
|
"emitters": [{ "shape": "sphere", "position": { "x": 0, "y": -400, "z": 0 }, "size": { "x": 52, "y": 52, "z": 52 },
|
|
208
217
|
"rate": 900, "lifetime": { "value": 4, "random": 1.4 }, "speed": { "value": 180, "random": 45 },
|
|
209
218
|
"direction": { "x": 0, "y": 1, "z": 0 }, "spread": 35, "variance": 0.35, "stir": { "strength": 80, "radius": 120 } }],
|
|
@@ -235,25 +244,25 @@ To bend that plume with a fan, add `"forceEmitters": [{ "id": "b1", "kind": "jet
|
|
|
235
244
|
}
|
|
236
245
|
```
|
|
237
246
|
|
|
238
|
-
The other fluid presets differ from these only in numbers: *Cigarette* is Rising Smoke
|
|
247
|
+
The other fluid presets differ from these only in numbers: *Cigarette* is Rising Smoke as a thin column: a 5 px `sphere` source at rate 6400 (24,000 particles), `spread: 3`, `fidelity: "high"`, `bounds: "open"` and faint additive sprites; *Ink Drop* is `model: "fluid"` (`force: 320`, `swirl: 30`, `fidelity: "high"`), a `sphere` source shooting down at 420 px/s under gravity, and blue alpha sprites; *Ember Swirl* is `model: "fluid"` (`swirl: 170`) plus `forces.vortex: { "strength": 230, "scale": 420, "mode": "vortex", "axis": "z", "lift": 0.15 }` and additive orange embers.
|
|
239
248
|
|
|
240
249
|
**`render`** — evaluated while drawing, so **every field here is free**: changing it never re-bakes.
|
|
241
250
|
|
|
242
251
|
| Field | Range | Meaning |
|
|
243
252
|
| --- | --- | --- |
|
|
244
|
-
| `mode` | `sprite` \| `layers` | `layers` stamps `objects.members` instead of a procedural sprite. |
|
|
253
|
+
| `mode` | `sprite` \| `layers` \| `lines` | `layers` stamps `objects.members` instead of a procedural sprite; `lines` joins particles into ribbons (see [Lines](#lines-rendermode-lines--trails-and-ready-made-lines)). |
|
|
245
254
|
| `sprite` | `soft-disc` \| `star` \| `spark` | Procedural, resolution-independent. |
|
|
246
255
|
| `blend` | `alpha` \| `additive` | Alpha systems are depth-sorted back-to-front; additive is order-independent. |
|
|
247
256
|
| `size` | `{start, end, random}` px | Size over life, ± per particle. |
|
|
248
257
|
| `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. |
|
|
249
|
-
| `colorMode` | `age` \| `source` \| `velocity` | Where RGB comes from. `source` needs a `layer` emitter. (
|
|
258
|
+
| `colorMode` | `age` \| `source` \| `velocity` | Where RGB comes from. `source` needs a `layer` emitter. (`colorFrom: 'ramp'\|'source'` migrates to `age`/`source`.) |
|
|
250
259
|
| `velocityRamp` / `velocityRange` | ramp / `{min, max}` px/s | The `velocity` gradient and the speeds that read its ends (`max` is forced above `min`). |
|
|
251
260
|
| `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. |
|
|
252
261
|
| `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`. |
|
|
253
262
|
|
|
254
263
|
#### Emitting from a layer (`shape: "layer"`) — dissolve a picture into its own pixels
|
|
255
264
|
|
|
256
|
-
Particles are born on the artwork's opaque pixels and can keep the colour of the pixel they came from. **How the sites are picked:** the picture is scaled down to at most 512 px on its long side (smoothed, so fine detail softens), every pixel with alpha above 8 is a site, and births pick evenly among the sites: alpha does not weight them, so density is the NUMBER of opaque pixels in an area — draw a dense region with more pixels, not brighter ones. Give the picture as a file or link (`asset:`, `/media/…`, https): a `url` is cut at 2,048 characters, so a data URL does not fit. The artwork is a **bounded snapshot on the emitter**, never a live reference — the source layer can be trimmed, hidden or deleted without breaking the system. `fromItemId` is only a hint
|
|
265
|
+
Particles are born on the artwork's opaque pixels and can keep the colour of the pixel they came from. **How the sites are picked:** the picture is scaled down to at most 512 px on its long side (smoothed, so fine detail softens), every pixel with alpha above 8 is a site, and births pick evenly among the sites: alpha does not weight them, so density is the NUMBER of opaque pixels in an area — draw a dense region with more pixels, not brighter ones. Give the picture as a file or link (`asset:`, `/media/…`, https): a `url` is cut at 2,048 characters, so a data URL does not fit. The artwork is a **bounded snapshot on the emitter**, never a live reference — the source layer can be trimmed, hidden or deleted without breaking the system. `fromItemId` is only a hint for finding that layer again when the artwork is refreshed from it.
|
|
257
266
|
|
|
258
267
|
```json
|
|
259
268
|
{ "shape": "layer", "emit": "continuous", "rate": 0,
|
|
@@ -274,7 +283,7 @@ Particles are born on the artwork's opaque pixels and can keep the colour of the
|
|
|
274
283
|
- `{ "kind": "text", "text", "fontFamily", "fontWeight" 100–1000, "fontSize" 4–2000, "lineHeight" 0.5–4, "color" }`
|
|
275
284
|
- `{ "kind": "shape", "points": [{x, y, hIn?, hOut?}] (2–256, unit space), "closed", "fill", "stroke", "strokeWidth", "aspect" }`
|
|
276
285
|
|
|
277
|
-
To match what the editor
|
|
286
|
+
To match what the editor writes for a layer source (and what makes it read as a dissolve rather than a leak):
|
|
278
287
|
|
|
279
288
|
- `emitter.size` = the layer's real on-screen rect, `emitter.position` = its position — a fraction of the frame reproduces the shape at the wrong scale and in the wrong place;
|
|
280
289
|
- `solver.preRoll: 0` (the picture must be whole at frame 0, not already blown apart), `rate: 0`, one burst of the full `maxParticles` at `time: 0`, `speed: 0` — the forces do the moving;
|
|
@@ -305,7 +314,7 @@ With `grow` on, emission stops being a rate and becomes a schedule: `rate`, `bur
|
|
|
305
314
|
|
|
306
315
|
With `render.mode: "layers"`, each particle stamps one of up to 12 members. Objects are a **render-only** concern — the solver moves anonymous points — so adding, removing or resizing them never re-bakes.
|
|
307
316
|
|
|
308
|
-
`members[]`: `{ id, name, fromItemId?, source, included, weight 0–10, sizeScale 0.05–20, spinScale 0–20 }`, where `source` is the same snapshot shape as a layer emitter's. Block-level: `pick` (`cycle`\|`random`), `facing` (`billboard`\|`tumble`\|`velocity`), `size` px, `sizeRandom` 0–1, `spin` 0–20, `keepRatio`, `tint` (multiply the artwork by the life ramp). A member with no usable artwork is dropped rather than kept as a blank row. Stamped artwork needs far fewer particles than dots — the editor thins a system to ~260
|
|
317
|
+
`members[]`: `{ id, name, fromItemId?, source, included, weight 0–10, sizeScale 0.05–20, spinScale 0–20 }`, where `source` is the same snapshot shape as a layer emitter's. Block-level: `pick` (`cycle`\|`random`), `facing` (`billboard`\|`tumble`\|`velocity`), `size` px, `sizeRandom` 0–1, `spin` 0–20, `keepRatio`, `tint` (multiply the artwork by the life ramp). A member with no usable artwork is dropped rather than kept as a blank row. Stamped artwork needs far fewer particles than dots — the editor thins a system to ~260 when the artwork is first set.
|
|
309
318
|
|
|
310
319
|
#### Lines (`render.mode: "lines"`) — trails and ready-made lines
|
|
311
320
|
|
|
@@ -349,6 +358,16 @@ With `render.mode: "layers"`, each particle stamps one of up to 12 members. Obje
|
|
|
349
358
|
|
|
350
359
|
Move the plume with the smoke source's `position` / `motion` (`sweep` ripples the rows); `return.strength` sets how far a row is pulled before it settles (0 = carried off with the smoke).
|
|
351
360
|
|
|
361
|
+
#### Particles as 3D objects in a world Scene (`particleLook: "3d"`)
|
|
362
|
+
|
|
363
|
+
When the project's Scene layer is drawn by the world renderer (world3d.md (GreenLight Dash only)), a particle item with `"particleLook": "3d"` (next to `particleSystem` on the item) leaves the 2D particle pass and joins the world as real objects: every particle is a small lit, shadowed mesh, traced in Final 3D. The world reads the system, its timing on the timeline, `render.size.start` for the particle size and the most opaque colour of `colorOverLife` as the one colour. Omit the field for the default **Glow** look (soft light on top of the picture). Without a world Scene the field does nothing.
|
|
364
|
+
|
|
365
|
+
```jsonc
|
|
366
|
+
{ "id": "prtitem1", "clipId": "prt1", "trimStart": 0, "trimEnd": 6, "freeStart": 0,
|
|
367
|
+
"particleLook": "3d",
|
|
368
|
+
"particleSystem": { /* as in the example above */ } }
|
|
369
|
+
```
|
|
370
|
+
|
|
352
371
|
#### What re-bakes and what is free
|
|
353
372
|
|
|
354
373
|
Only simulation inputs invalidate the cache: `seed`, `solver`, `emitters[0]` (including `rows`), `forces` (including `return`), the layer emitter's artwork, and the *timing* parts of `activation` (`mode`, `inactive`, `start`, `speed`, `reverse`, `softness`). Everything in `render` (including `mode` and `lines`), `objects`, `quality`, plus `activation.ignite*`, is evaluated at draw time. Useful when a user asks for "the same motion, different colour" — that edit is instant and byte-identical in simulation.
|
|
@@ -4,29 +4,39 @@
|
|
|
4
4
|
> here. `frontend/src/…` paths are files of the engine's public source repository. MotionFast templates (`mediaType: "motion"`)
|
|
5
5
|
> stay in GreenLight Dash.
|
|
6
6
|
|
|
7
|
-
# GreenLight Dash — Motion paths
|
|
7
|
+
# GreenLight Dash — Motion paths and text on path
|
|
8
8
|
|
|
9
9
|
> Read [video-projects.md](video-projects.md) for the shared project, clip, channel, timing and versioned-write contracts. Load only the sections needed for the task.
|
|
10
10
|
|
|
11
11
|
## Contents
|
|
12
12
|
|
|
13
|
-
- [Object Motion Paths (`motionPath` + `motionPathT`)](
|
|
14
|
-
- [
|
|
13
|
+
- [Object Motion Paths (`motionPath` + `motionPathT`)](#object-motion-paths-motionpath--motionpatht)
|
|
14
|
+
- [Ready-made path shapes](#ready-made-path-shapes)
|
|
15
|
+
- [Smoothing / reducing any path — `POST /api/paths/smooth`](#smoothing--reducing-any-path--post-apipathssmooth)
|
|
16
|
+
- [Text on Path (`textPathEnabled`)](#text-on-path-textpathenabled)
|
|
17
|
+
- [Animated motion path (`motionPathPoints`)](#animated-motion-path-motionpathpoints)
|
|
18
|
+
- [Motion-track follow (`motionTracks` + `motionFollow`)](#motion-track-follow-motiontracks--motionfollow) — in tracking-data.md (GreenLight Dash only)
|
|
15
19
|
|
|
16
20
|
### Object Motion Paths (`motionPath` + `motionPathT`)
|
|
17
21
|
|
|
18
|
-
Any ordinary visual timeline item can follow a saved 3D bezier path. The path is stored on the **item**, not the clip. Points use world pixels: origin at the composition centre, `x` right, `y` up, and `z` toward the viewer. `hIn`/`hOut` are tangent **offsets from their own anchor**, not absolute control
|
|
22
|
+
Any ordinary visual timeline item can follow a saved 3D bezier path. The path is stored on the **item**, not the clip. Points use world pixels: origin at the composition centre, `x` right, `y` up, and `z` toward the viewer; world-space paths are unclamped. `hIn`/`hOut` are tangent **offsets from their own anchor**, not absolute control points: the segment A→B is the cubic `A → A+A.hOut → B+B.hIn → B`.
|
|
23
|
+
|
|
24
|
+
**Write both handles and `manual: true` on every anchor.** A missing or zero-length handle is a corner, and nothing fills one in: load, save, preview, export and `/render-onserver` sample the stored handles verbatim, and the auto-smoothing pass runs only on a hand edit in the editor's 3D view. A path written as bare `x`/`y`/`z` points therefore flies straight lines between anchors, and can change shape the first time it is opened in that view. Compute smooth handles with the tangent rule in [Writing the handles](video-shapes.md#writing-the-handles) (also in `GET /api/video-editor/capabilities` → `paths.tangents`); in world space every handle also carries `z`.
|
|
19
25
|
|
|
20
26
|
```json
|
|
21
27
|
{
|
|
22
28
|
"motionPath": {
|
|
23
29
|
"points": [
|
|
24
|
-
{ "x": -520, "y": -120, "z": 0
|
|
30
|
+
{ "x": -520, "y": -120, "z": 0,
|
|
31
|
+
"hIn": { "x": -173.3, "y": -100, "z": -40 },
|
|
32
|
+
"hOut": { "x": 173.3, "y": 100, "z": 40 }, "manual": true },
|
|
25
33
|
{ "x": 0, "y": 180, "z": 120,
|
|
26
34
|
"hIn": { "x": -180, "y": -40, "z": -20 },
|
|
27
35
|
"hOut": { "x": 180, "y": 40, "z": 20 }, "manual": true,
|
|
28
36
|
"rx": 15, "ry": 0, "rz": -30 },
|
|
29
|
-
{ "x": 520, "y": -80, "z": 0
|
|
37
|
+
{ "x": 520, "y": -80, "z": 0,
|
|
38
|
+
"hIn": { "x": -173.3, "y": 86.7, "z": 40 },
|
|
39
|
+
"hOut": { "x": 173.3, "y": -86.7, "z": -40 }, "manual": true }
|
|
30
40
|
],
|
|
31
41
|
"closed": false,
|
|
32
42
|
"follow": true,
|
|
@@ -47,27 +57,51 @@ Any ordinary visual timeline item can follow a saved 3D bezier path. The path is
|
|
|
47
57
|
```
|
|
48
58
|
|
|
49
59
|
- A path is live only when it has at least two valid points and `follow: true`.
|
|
50
|
-
- `motionPathT` is 0–100. On an open path, 0 is the first anchor and 100 the last. On a closed path, 100 completes the loop back to the first anchor.
|
|
60
|
+
- `motionPathT` is 0–100. On an open path, 0 is the first anchor and 100 the last. On a closed path, 100 completes the loop back to the first anchor. Progress tracks take `Linear` (constant speed); an eased progress crawls at both ends.
|
|
51
61
|
- `aim: "tangent"` aligns the layer's local +X axis with the curve; `aim: "points"` orients the layer from the anchor rotations alone (below); `aim: "free"` preserves its ordinary rotation/keyframes.
|
|
52
62
|
- Anchors may carry a per-point rotation `rx`/`ry`/`rz` (degrees, same sign convention as the layer Rotate fields; omit when all zero — the sanitizer strips all-zero triples), blending linearly between anchors over each segment so the layer banks/tilts/spins into a rotated point as it travels. With `aim: "tangent"` it is applied **in the aimed local frame** on top of the tangent aim; with `aim: "points"` the blended anchor rotation drives orientation by itself on world axes — no tangent alignment — falling back to Free behaviour while no anchor is rotated.
|
|
53
63
|
- While Follow Path is live, the path owns local X/Y/Z position. Turn `follow` off before editing `translateX/Y/Z` as the visible position authority. Scale remains independent; Free aim also leaves rotation independent.
|
|
54
|
-
- **Offset from path** — `motionPathOffsetX`/`Y`/`Z` on the item, px,
|
|
55
|
-
- **Copying a curve between layers**: motion paths and camera paths share this world-pixel space, so `points`/`closed` can be assigned across them literally. A shape outline
|
|
64
|
+
- **Offset from path** — `motionPathOffsetX`/`Y`/`Z` on the item, px, with +X right, +Y **down**, +Z toward the viewer. A constant displacement from the sampled curve, applied on **world axes** (it does not swing with the tangent), so several layers can share one path and hold a formation. It moves the rider, never the curve: the aim, the drawn overlay and the anchors are unchanged. No-op while `follow` is false. Each axis is a keyframable track of the same name.
|
|
65
|
+
- **Copying a curve between layers**: motion paths and camera paths share this world-pixel space, so `points`/`closed` can be assigned across them literally. A shape outline lives in its own unit box: convert it through the source shape's Width/Height, `zoom` and Position to land where the shape is drawn (the editor does this when it copies a path).
|
|
56
66
|
- `keyframesPerPoint*` is bookkeeping for the "one key per anchor" convenience: one evenly-spaced `motionPathT` key per anchor, a closed path adding the final 100 key back to anchor one. Omit it and author any valid progress track directly.
|
|
57
67
|
- The normal preview, 3D view, WebCodecs export, and `/render-onserver` all evaluate the same curve.
|
|
58
|
-
- **To draw the curve as a visible trail**, add a `line` shape layer on its own channel below the rider
|
|
59
|
-
|
|
68
|
+
- **To draw the curve as a visible trail**, add a `line` shape layer on its own channel below the rider. Take the path's bounding box with the handle ends included (`w`, `h`, centre `cx`, `cy`), size the line to it (`shapeSizeUnit: "px"`, `shapeWidthPx: w`, `shapeHeightPx: h`), pan it onto the box centre, and write its points as `x = (X − cx) / w`, `y = −(Y − cy) / h` (handles: `hx = HX / w`, `hy = −HY / h`) — a closed path becomes an open line with its first anchor repeated at the end. Set `panZ` to the path's mid-depth plus the rider's `motionPathOffsetZ` **minus 2**: coplanar with the layer riding it, the two planes would intersect under any camera move. The trail is a snapshot; the two are independent afterwards.
|
|
69
|
+
|
|
70
|
+
#### Ready-made path shapes
|
|
71
|
+
|
|
72
|
+
The editor's 19 Motion Path presets, centred on the composition. Coordinates are multiples of `s = 0.2315 × min(width, height)` (250 px on a 1920×1080 or 1080×1920 frame), y up, `z` = the layer's depth; round to 0.01 px. **smooth** = handles from the [tangent rule](video-shapes.md#writing-the-handles) (open or closed as listed); **corner** = zero handles; listed handles are exact, unlisted ones are zero.
|
|
73
|
+
|
|
74
|
+
| Preset | Kind | Anchors (× s) |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| Arc | open, smooth | (−1.45, −0.55), (0, 0.62), (1.45, −0.55) |
|
|
77
|
+
| S-Curve | open | (−1.5, −0.65) with hOut (0.95, 0); (1.5, 0.65) with hIn (−0.95, 0) |
|
|
78
|
+
| Corner | open | (−1.35, −0.8) with hOut (0, 0.75); (1.4, 0.55) with hIn (−1.5, 0) |
|
|
79
|
+
| U-Turn | open, smooth | (1.5, 0.55), (−0.4, 0.55), (−1.05, 0), (−0.4, −0.55), (1.5, −0.55) |
|
|
80
|
+
| Wave | open, smooth | k = 0…5: (−1.5 + 0.6k, +0.42 for even k / −0.42 for odd k) |
|
|
81
|
+
| Zigzag | open, corners | the Wave anchors |
|
|
82
|
+
| Bounce | open | (−1.5, 0.78) hOut (0.3, 0); (−0.75, −0.62) hIn (−0.2, 0.9333); then floor anchors at x = 0.1, 0.75, 1.2, 1.5 (y −0.62). Each hop of width `w` and height `h` between two floor anchors: left hOut (w/3, 4h/3), right hIn (−w/3, 4h/3), with (w, h) = (0.85, 0.62), (0.65, 0.4), (0.45, 0.24), (0.3, 0.13) |
|
|
83
|
+
| Loop | open, smooth | (−1.55, −0.37), (0.08, −0.37), (0.52, 0.06), (0.08, 0.48), (−0.36, 0.06), (0.12, −0.35), (1.55, −0.37) |
|
|
84
|
+
| Swing | open, smooth | 5 points on a circle of radius 1.6 centred at (0, 1.05), at 240°, 255°, 270°, 285°, 300° |
|
|
85
|
+
| Spring | open, smooth | k = 0…18, o = k/18, θ = 6π·o: (−1.35 + 2.7·o − 0.32·sin θ, 0.32·cos θ) |
|
|
86
|
+
| Spiral In | open, smooth | k = 0…14, i = k/14, θ = π + 4.5π·i, r = 0.97·(1 − i)^0.9 + 0.03: (1.3·r·cos θ, r·sin θ) |
|
|
87
|
+
| Spiral Out | open, smooth | the Spiral In anchors in reverse order, handles recomputed |
|
|
88
|
+
| Circle | closed | (r, 0) hOut (0, c); (0, r) hOut (−c, 0); (−r, 0) hOut (0, −c); (0, −r) hOut (c, 0); every hIn = −hOut; r = 0.95, c = 0.5523·r |
|
|
89
|
+
| Orbit | closed | the Circle construction as an ellipse: (1.4, 0) hOut (0, 0.2761); (0, 0.5) hOut (−0.7732, 0); (−1.4, 0) hOut (0, −0.2761); (0, −0.5) hOut (0.7732, 0); every hIn = −hOut |
|
|
90
|
+
| Figure 8 | closed, smooth | k = 0…7, a = k·π/4: (1.4·sin a, 0.72·sin 2a) |
|
|
91
|
+
| Heart | closed | k = 0…11, θ = k·π/6, u = 0.072: (16u·sin³θ, u·(13 cos θ − 5 cos 2θ − 2 cos 3θ − cos 4θ) + 0.18); k = 0 and 6 are corners, the rest smooth |
|
|
92
|
+
| Square | closed | a rounded rectangle, half-size 1.15 × 0.68, corner radius 0.32, c = 0.1767: (1.15, −0.36) hIn (0, −c); (1.15, 0.36) hOut (0, c); (0.83, 0.68) hIn (c, 0); (−0.83, 0.68) hOut (−c, 0); (−1.15, 0.36) hIn (0, c); (−1.15, −0.36) hOut (0, −c); (−0.83, −0.68) hIn (−c, 0); (0.83, −0.68) hOut (c, 0) |
|
|
93
|
+
| Triangle | closed, corners | (0, 0.78), (−1.05, −0.55), (1.05, −0.55) |
|
|
94
|
+
| Star | closed, corners | 5 anchors in pentagram order, angle 90° + 72°·t for t = 0, 2, 4, 1, 3: (1.05·cos, 0.9·sin) |
|
|
60
95
|
|
|
61
96
|
#### Smoothing / reducing any path — `POST /api/paths/smooth`
|
|
62
97
|
|
|
63
|
-
A traced, tracked or hand-drawn path arrives with far more anchors than anyone wants to fine-tune (a rotoscoped mask, a motion-tracker export, a 44-point spiral
|
|
98
|
+
A traced, tracked or hand-drawn path arrives with far more anchors than anyone wants to fine-tune (a rotoscoped mask, a motion-tracker export, a 44-point spiral). This endpoint rewrites a point list with fewer, cleaner anchors, and it is the **same code the editor's path smoothing runs**, so an agent-smoothed path is identical to one smoothed in the editor.
|
|
64
99
|
|
|
65
|
-
It is
|
|
100
|
+
It is **stateless and path-agnostic** — every path in the app stores the same anchor shape, so read the points out of the project, post them here, and write the result back where you found them. Works for `item.motionPath.points`, `item.shapePath.points`, `item.cameraPath.points`, a layer mask's `path.points`, and any `shapePath`/`msk:<mid>:path`/`motionPathPoints` keyframe value.
|
|
66
101
|
|
|
67
102
|
```json
|
|
68
|
-
POST /api/paths/smooth
|
|
69
103
|
{
|
|
70
|
-
"points": [ { "x": 0, "y": 0, "hIn": {
|
|
104
|
+
"points": [ { "x": 0, "y": 0, "hIn": { "x": 0, "y": 0 }, "hOut": { "x": 0, "y": 0 }, "manual": true } ],
|
|
71
105
|
"closed": false,
|
|
72
106
|
"mode": "fit",
|
|
73
107
|
"amount": 34,
|
|
@@ -77,6 +111,8 @@ POST /api/paths/smooth
|
|
|
77
111
|
}
|
|
78
112
|
```
|
|
79
113
|
|
|
114
|
+
Body fields (defaults): `points` (required, 3–5000 anchors; the example shows one), `closed` (false), `mode` (`fit`), `amount` (34), `epsilon` (null — overrides `amount`), `corner_deg` (34), `limit_points` (true) with `max_points` (24) — caps the anchor count, `even_count` (12, for `even`), `relax_strength` (45), `relax_passes` (4) and `relax_pin_corners` (true, for `relax`), and `kept_indexes` (replay a `reduce`, below).
|
|
115
|
+
|
|
80
116
|
Response: `{ "points": [...], "stats": { "before", "after", "removed", "corners", "maxDeviation", "maxDeviationPct", "meanDeviation", "lengthDeltaPct", "epsilon", "keptIndexes" } }`. The returned points are ready to assign straight back (handles as offsets, `manual: true`, `z` only when the input had it).
|
|
81
117
|
|
|
82
118
|
**Pick the mode by what must be preserved:**
|
|
@@ -88,9 +124,9 @@ Response: `{ "points": [...], "stats": { "before", "after", "removed", "corners"
|
|
|
88
124
|
| `even` | Rebuilds with exactly `even_count` anchors at equal arc length | Exactly `even_count` | A specific count is needed (e.g. to match another key) |
|
|
89
125
|
| `relax` | Averages out the shake, keeping every anchor | **Unchanged** | Tracked / hand-drawn paths whose count is fine but whose shape wobbles |
|
|
90
126
|
|
|
91
|
-
- `amount` (0–100) is the single tolerance knob; ~8 is a light tidy-up, ~48 is aggressive. Pass `epsilon` instead to set the tolerance directly in the path's own units. Tolerance is measured as a share of the path's bounding box, so the same `amount` behaves the same on a
|
|
92
|
-
- `corner_deg`
|
|
93
|
-
- Check `stats.maxDeviationPct` before writing the result back — under ~0.4% of the bounding box is visually identical, over ~1.5% is a visible shape change. `mode: "even"` with a small count can move the curve a long way; the number tells you so.
|
|
127
|
+
- `amount` (0–100) is the single tolerance knob; ~8 is a light tidy-up, ~48 is aggressive. Pass `epsilon` instead to set the tolerance directly in the path's own units. Tolerance is measured as a share of the path's bounding box, so the same `amount` behaves the same on a 1920 px motion path and a ±0.5 unit-box mask.
|
|
128
|
+
- `corner_deg` keeps turns sharper than that many degrees as **corners**. This is what lets a star stay a star: at the default a traced 10-point star refits to exactly 10 anchors with zero deviation, while `corner_deg: 180` (corner detection off) spends 20 anchors and is still less accurate. Raise it to deliberately round a spiky path off.
|
|
129
|
+
- Check `stats.maxDeviationPct` before writing the result back — under ~0.4 % of the bounding box is visually identical, over ~1.5 % is a visible shape change. `mode: "even"` with a small count can move the curve a long way; the number tells you so.
|
|
94
130
|
- **A keyframed path must keep the same point count in every key** — a shape morph, a mask path track and a one-key-per-anchor `motionPathT` track all pair point 3 of one key with point 3 of the next. So: smooth the key you can see with `mode: "reduce"`, then replay that exact reduction on every other key by posting each one with `{"points": <that key>, "kept_indexes": <stats.keptIndexes from the first call>}`. `mode: "even"` (same `even_count` for every key) and `mode: "relax"` are also safe. **`mode: "fit"` is not** — it picks its own count per key, so key 1 lands on 9 anchors, key 2 on 11, and the morph breaks.
|
|
95
131
|
- Changing the count of a motion/camera path invalidates a one-key-per-anchor progress track: rebuild `motionPathT` / `camPathT` with evenly spaced keys (see `keyframesPerPoint` above) or leave the plain 0→100 pair.
|
|
96
132
|
- Errors are HTTP 400: fewer than 3 points, more than 5000, an unknown `mode`, or a degenerate (zero-length) path.
|
|
@@ -99,7 +135,7 @@ Response: `{ "points": [...], "stats": { "before", "after", "removed", "corners"
|
|
|
99
135
|
|
|
100
136
|
A **text** item with a motion path can lay its glyphs out along the curve instead of (or as well as) riding it. Fields (all on the item, sanitized defaults shown):
|
|
101
137
|
|
|
102
|
-
```
|
|
138
|
+
```jsonc
|
|
103
139
|
{
|
|
104
140
|
"textPathEnabled": true,
|
|
105
141
|
"textPathGrain": "symbols", // 'symbols' (letters) | 'words' — one pose per unit
|
|
@@ -116,21 +152,21 @@ A **text** item with a motion path can lay its glyphs out along the curve instea
|
|
|
116
152
|
}
|
|
117
153
|
```
|
|
118
154
|
|
|
119
|
-
|
|
155
|
+
Strip the `//` comments before sending.
|
|
156
|
+
|
|
157
|
+
- Placement is arc-length parameterised (kerning survives corners) and uses the same curve evaluation as Follow Path, so preview, WebCodecs export and `/render-onserver` agree.
|
|
120
158
|
- **Progress reuses `motionPathT` + its Path Progress track** and moves every glyph along the curve (not the block as one object). A closed untrimmed loop wraps like a marquee; an open or trimmed path extrapolates straight past its end tangents, so text can slide onto/off the curve. The trims move the WINDOW the run is laid out in — they do NOT hide letters (those run on past the window's ends), so keyed trims are not a write-on: use a Text Animator reveal for that. Letters that run past the path's own ends are cut at the text plate's edge — extend the path to run text off the frame.
|
|
121
|
-
- `textPathDirection` decides where the run rests at 0% and which way progress carries it: `forward` parks the text ON the first point and travels to the last, `backward` ends it on the last point and travels back to the first, `center` (default)
|
|
122
|
-
- `textPathTravel` sets how far 100% carries a forward/backward run: `fit` (default) stops when the run's far end reaches the far point, so the whole text stays on the path; `through` keeps going until the leading unit gets there, sliding the text off the end. Travel is computed per row (each row's run has its own length), and a run longer than the trimmed window has nowhere to travel under `fit`. Ignored by `center` (which always slides the full window) and by a wrapping closed loop (always a whole lap).
|
|
159
|
+
- `textPathDirection` decides where the run rests at 0 % and which way progress carries it: `forward` parks the text ON the first point and travels to the last, `backward` ends it on the last point and travels back to the first, `center` (default) anchors the run in the middle of the window and follows `textAlign` (start/centre/end of the trimmed window). Multi-line text stacks rows along the curve normal at line-height steps.
|
|
160
|
+
- `textPathTravel` sets how far 100 % carries a forward/backward run: `fit` (default) stops when the run's far end reaches the far point, so the whole text stays on the path; `through` keeps going until the leading unit gets there, sliding the text off the end. Travel is computed per row (each row's run has its own length), and a run longer than the trimmed window has nowhere to travel under `fit`. Ignored by `center` (which always slides the full window) and by a wrapping closed loop (always a whole lap).
|
|
123
161
|
- `textPathCorners: "sharp"` treats bends as corners (a turn of 20°+ within ¾ of the type size, or half the unit if wider): a unit never takes an in-between angle on a bend — it belongs wholly to ONE side, turned to that side and laid on that side's straight line carried through the bend. The side is the one it RESTS on (Path Progress 0), so a glide or drift shorter than half a unit never turns a letter at a corner; a unit carried further (a marquee round a square) moves across when its centre passes the middle of the bend. Use it for text running or gliding round a square / ∩ path (rounded corners included); keep `smooth` for curves (an outline, a wave).
|
|
124
162
|
- While `textPathEnabled` is live (path has ≥2 points), `follow` is ignored — the glyph run owns the curve and the layer's own transform stays put. The caption background bar (`textBgEnabled`) is suppressed on a path; split/in-out animations, highlight, randomizer and word timings all compose per unit on the curve.
|
|
125
163
|
- `follow` is NOT required: draw a path, leave `follow: false`, set `textPathEnabled: true`.
|
|
126
164
|
|
|
127
165
|
#### Animated motion path (`motionPathPoints`)
|
|
128
166
|
|
|
129
|
-
The path ITSELF can be keyframed, like a Layer Mask outline: `propertyKeyframes.motionPathPoints` holds snapshots of the
|
|
130
|
-
points, and Follow Path and Text on Path both ride the snapshot at the current frame — so text can follow a tracked
|
|
131
|
-
(RotoScope) outline frame by frame.
|
|
167
|
+
The path ITSELF can be keyframed, like a Layer Mask outline: `propertyKeyframes.motionPathPoints` holds snapshots of the points, and Follow Path and Text on Path both ride the snapshot at the current frame — so text can follow a tracked (RotoScope) outline frame by frame.
|
|
132
168
|
|
|
133
|
-
```
|
|
169
|
+
```jsonc
|
|
134
170
|
"propertyKeyframes": {
|
|
135
171
|
"motionPathPoints": [
|
|
136
172
|
{ "position": 0, "value": "[{\"x\":-520,\"y\":0,\"z\":0,\"hIn\":{\"x\":0,\"y\":0,\"z\":0},\"hOut\":{\"x\":0,\"y\":0,\"z\":0},\"manual\":true}, …]", "easing": "Linear" },
|
|
@@ -139,45 +175,13 @@ points, and Follow Path and Text on Path both ride the snapshot at the current f
|
|
|
139
175
|
}
|
|
140
176
|
```
|
|
141
177
|
|
|
142
|
-
- **Value:** a JSON string of the points in the WORLD space of `motionPath` (pixels, origin at the comp centre, +y up,
|
|
143
|
-
|
|
144
|
-
- **
|
|
145
|
-
|
|
146
|
-
- **
|
|
147
|
-
|
|
148
|
-
- **In the editor:** MOTION PATH ▸ **Animate path** keys the path at the playhead; from then on every canvas, 3D-view
|
|
149
|
-
or panel edit keys it where the playhead is (adding or deleting an anchor changes every key, split at the same spot
|
|
150
|
-
of the curve). **Copy path** from an ANIMATED source — a RotoScope mask, a keyed shape path or another animated
|
|
151
|
-
motion path (the menu shows `n◆`) — brings all its keys, at their absolute times on this layer's span, cut with an
|
|
152
|
-
interpolated key where the source runs past either end.
|
|
153
|
-
- **Text on Path:** the text plate is sized from the reach of ALL keys once, so the outline can move without the
|
|
154
|
-
texture being reallocated.
|
|
178
|
+
- **Value:** a JSON string of the points in the WORLD space of `motionPath` (pixels, origin at the comp centre, +y up, z toward the viewer, handles as offsets, optional `rx/ry/rz`). `closed`, `follow`, `aim` stay on `item.motionPath`.
|
|
179
|
+
- **Same point count in every key**; the shorter list wins if they differ. Dense (per-frame) tracks use `Linear` keys.
|
|
180
|
+
- **Keep `item.motionPath`** with the same points (any key's): the track is only kept while the layer has a path of 2+ points, and point counts / Text on Path liveness read it.
|
|
181
|
+
- **Adding or deleting an anchor** changes every key, split at the same spot of the curve.
|
|
182
|
+
- **Copying from an ANIMATED source** (a RotoScope mask, a keyed shape path or another animated motion path) brings all its keys, at their absolute times on this layer's span, cut with an interpolated key where the source runs past either end, as the editor's copy does.
|
|
183
|
+
- **Text on Path:** the text plate is sized from the reach of ALL keys once, so the outline can move without the texture being reallocated.
|
|
155
184
|
|
|
156
185
|
### Motion-track follow (`motionTracks` + `motionFollow`)
|
|
157
186
|
|
|
158
|
-
A
|
|
159
|
-
|
|
160
|
-
Tracks arrive from either of the editor's two analyses, and both land in the same array with the same shape: **Motion Tracking** (the user drew a box around one detail) and **Camera Tracking** (the whole frame is solved, the user picks tracking points off the result, and each null they create writes one track here). A camera-tracked null is an ordinary shape layer — a 20 px `ellipse` at `opacity: 0` — whose `motionFollow` pins it to its track, so nothing about following, rendering or copying is special-cased for it. There is nothing to distinguish the two kinds after the fact, and nothing that should be.
|
|
161
|
-
|
|
162
|
-
What an agent **can** do is attach any visual layer to an existing track — "make this text ride the car", "pin this badge to the player" — by writing `motionFollow` on the follower item:
|
|
163
|
-
|
|
164
|
-
```json
|
|
165
|
-
"motionFollow": {
|
|
166
|
-
"itemId": "<id of the timeline item that owns the track — the follower's own id for a self-follow>",
|
|
167
|
-
"trackId": "<a track id from that item's motionTracks>",
|
|
168
|
-
"mode": "ride",
|
|
169
|
-
"position": true,
|
|
170
|
-
"scale": true,
|
|
171
|
-
"rotation": true,
|
|
172
|
-
"smoothing": 0
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
- `mode` — what the link does with the tracked motion. `"ride"` (default): keep the follower's own transform and **add** the motion to it. `"pin"`: put the follower **on** the tracked point, ignoring its own placement — right for a badge or arrow that should sit on the subject with nothing to line up. `"lock"`: cancel the motion so the tracked subject stands still and the footage moves under it (stabilisation) — only meaningful as a **self-follow** on the item that owns the track (`itemId` = the follower's own id); on any other layer it swings that layer around a distant pivot.
|
|
177
|
-
- **The offset rule (ride mode).** A track contributes zero motion at its `refTime`; wherever the follower's own transform puts it at that moment **is** the offset — there are no offset fields. So author the link in two steps: set `motionFollow`, then set the follower's position/keyframes so it sits where it should at the track's reference frame. `refTime` is in the source clip's seconds; the matching timeline moment is where the track-owning item plays that source second — its placed start plus `(refTime - trimStart)`, adjusted for any speed change on that item.
|
|
178
|
-
- `position` / `scale` / `rotation` — booleans choosing which motion components apply; omitted means `true`.
|
|
179
|
-
- `smoothing` — integer 0–30, ± that many source frames averaged; the knob that makes a jittery hand-held track usable without re-analysis.
|
|
180
|
-
- The source item should share screen time with the follower; the link is by item id + track id, not stack position. A dangling reference (deleted layer or track) is **kept and no-ops** — nothing errors, the follower just stops moving — so verify both ids exist when a follow seems dead.
|
|
181
|
-
- Applied identically in preview and every render path, including `/render-onserver`; gaps in the track (occlusions recorded in `segments`) are interpolated across.
|
|
182
|
-
|
|
183
|
-
* * *
|
|
187
|
+
A layer that rides a motion-tracked point (`motionFollow`: ride, pin, lock, and the offset rule) and the track data it reads (`item.motionTracks`) are documented in tracking-data.md (GreenLight Dash only).
|