greenlight-motion 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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](video-particles.md#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 in a world Scene](#particles-in-a-world-scene)
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 now sizes itself to what the emitter can actually reach. Raising the
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 and by the inspector.
43
- - **`preset` is metadata, not a reference.** Writing `"preset": "snow"` does **not** produce snow — the app materialises a complete system when the user clicks a preset. Author the actual values (start from the table below, or copy the preset numbers from `frontend/src/sim/particles/presets.js`).
44
- - Everything is passed through a bounded, version-aware sanitizer (`sanitizeParticleSystem` in `frontend/src/sim/particles/schema.js`): 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.
45
- - **Write `"schemaVersion": 4`.** A system with no `schemaVersion` is treated as version 1 and keeps `forces.turbulence.pattern: "smooth"` (the pre-v2 field), not the current `"billow"` default. Older payloads stay valid: every block added since defaults to OFF (`forces.fluid`, `forces.draft`, `forceEmitters`, `pathFollow`, `collision`, `render.dye`, `proximity`), so an earlier payload bakes exactly as it did. `packing` is the same: while `packing.enabled` is false it contributes nothing to the simulation identity and `render.size` stays a look-only field.
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` (the panel's **Density**) 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.
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; 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.
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. Forced by the sanitizer. |
107
- | `engine` | `auto` (default) · `live` · `baked` | How the EDITOR previews the system — not a simulation input and never part of a render. `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. Exports and `/render-onserver` always use the bake. Omit it. |
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. v3: `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). |
115
- | `dirMode` | `cone` \| `outward` \| `tangent` | v3, `ring`/`path` only: launch along `direction`+`spread`, away from the ring / normal to the path, or along it. |
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 | v3, 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. |
118
- | `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. |
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 [video-world3d.md](video-world3d.md#smoke-and-fire)). **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 (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.
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 [video-world3d.md](video-world3d.md#smoke-and-fire). 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` (the panel's **Driving fluid / smoke by ▸ Force emitters**, which sets both) and at least one entry in `forceEmitters`; ignored otherwise. |
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–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. |
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 you can write without the repo** — the overrides each preset applies on top of the sanitizer 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).
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": 0, "bounds": { "size": { "x": 1920, "y": 1080, "z": 480 }, "behavior": "kill" } },
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 with `fidelity: "flat-hd"`, a thin `point` source riding a `path` emitter, and `render.dye: { "mode": "card", "res": 1 }`; *Ink Drop* is `model: "fluid"`, a slow downward `sphere` source, high `swirl`, dark alpha sprites; *Ember Swirl* is `model: "fluid"` plus `forces.vortex: { "strength": 230, "scale": 420, "mode": "vortex", "axis": "z", "lift": 0.15 }` and additive orange embers.
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. (v1's `colorFrom: 'ramp'\|'source'` migrates to `age`/`source`.) |
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 so the editor's **Update artwork from layer** can find it again.
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 does when a user picks a layer (and what makes it read as a dissolve rather than a leak):
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 on the first pick.
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,10 @@ 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 in a world Scene
362
+
363
+ A particle layer is drawn flat over the picture. For particles that live in a world Scene as real objects (lit, shadowed, colliding with shapes, traced in Final 3D), write a simulation in the Scene instead: sim.md (GreenLight Dash only).
364
+
352
365
  #### What re-bakes and what is free
353
366
 
354
367
  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, text on path and motion follow
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`)](video-paths.md#object-motion-paths-motionpath--motionpatht)
14
- - [Motion-track follow (`motionTracks` + `motionFollow`)](video-paths.md#motion-track-follow-motiontracks--motionfollow)
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-point coordinates: the segment A→B is the cubic `A → A+A.hOut → B+B.hIn → B`. ⚠️ **Write the handles.** A missing or zero-length handle is a **corner** — nothing derives a tangent for you, because the auto-smoothing pass runs only when a human adds or drags a point in the 3D world view, while load, save, preview, export and `/render-onserver` sample the stored handles verbatim. A path written as bare `x`/`y`/`z` points therefore flies **straight lines** between anchors (and can silently change shape later, the first time the world view is opened on it). Set `manual: true` on every anchor as well, so that pass leaves the curve alone. The smooth tangent to write is the editor's own Catmull-Rom one — `hOut = (P[next] − P[prev]) / 6`, `hIn = −hOut`, componentwise; on this open-by-default path the first anchor uses `(P[1] − P[0]) / 3` and the last `−(P[last] − P[last−1]) / 3`, and a `closed` path wraps instead. `GET /api/video-editor/capabilities` → `paths` restates this contract; world-space paths are unclamped.
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,52 @@ 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. The range is shared equally between the segments, not by distance: with 3 anchors, 50 is the middle anchor however long each half is, so evenly spaced anchors give an even speed.
61
+ - **The whole curved move is two keys on `motionPathT`**, 0 → 100. Progress tracks take `Linear` (constant speed); an eased progress crawls at both ends. Ease the pair on purpose, for a layer that starts from rest and lands (`AE Smooth`, or `Power` to land): Linear there starts at full speed and stops dead. A layer that enters at a cut already moving takes a matched start and a soft landing (motiongram/adapters/glea.md (GreenLight Dash only)). Use a path whenever a layer should travel an arc, instead of `translateX` / `translateY` keys with different curves.
51
62
  - `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
63
  - 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
64
  - 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, panel signs (+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.
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 must be converted from its unit box through the source shape's own Width/Height, `zoom` and Position to land where the shape is drawn — `collectPathSources`/`cloneWorldPoints` in `frontend/src/components/videoEditor/pathCopy.js` do that conversion.
65
+ - **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.
66
+ - **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
67
  - `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
68
  - 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: size it to the path's bounding box (`shapeSizeUnit: 'px'` + `shapeWidthPx`/`shapeHeightPx`) and pan it onto that box's centre so the outline lands where the path runs. Set `panZ` to the path's mid-depth plus the layer's `motionPathOffsetZ` **minus 2** — coplanar with the layer riding it, the two planes intersect under any camera move. `shapeOutlineFromMotionPath` in `frontend/src/components/videoEditor/shapePath.js` does the world-pixel → unit-box conversion (a closed path comes back open with its first anchor repeated, because a Line is open by definition). It is a snapshot: the two are independent afterwards.
59
- - **19 ready-made path shapes** ship as recipes in `frontend/src/components/videoEditor/objectMotionPathPresets.js` — travel paths (Arc, S-Curve, Corner, U-Turn, Wave, Zigzag, Bounce, Loop, Swing, Spring, Spiral In/Out) and closed loops (Circle, Orbit, Figure 8, Heart, Square, Triangle, Star), each centred on the composition and scaled to the frame. Read the exact point/handle values from there rather than approximating a shape by hand.
69
+ - **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.
70
+
71
+ #### Ready-made path shapes
72
+
73
+ 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.
74
+
75
+ | Preset | Kind | Anchors (× s) |
76
+ | --- | --- | --- |
77
+ | Arc | open, smooth | (−1.45, −0.55), (0, 0.62), (1.45, −0.55) |
78
+ | S-Curve | open | (−1.5, −0.65) with hOut (0.95, 0); (1.5, 0.65) with hIn (−0.95, 0) |
79
+ | Corner | open | (−1.35, −0.8) with hOut (0, 0.75); (1.4, 0.55) with hIn (−1.5, 0) |
80
+ | U-Turn | open, smooth | (1.5, 0.55), (−0.4, 0.55), (−1.05, 0), (−0.4, −0.55), (1.5, −0.55) |
81
+ | Wave | open, smooth | k = 0…5: (−1.5 + 0.6k, +0.42 for even k / −0.42 for odd k) |
82
+ | Zigzag | open, corners | the Wave anchors |
83
+ | 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) |
84
+ | 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) |
85
+ | Swing | open, smooth | 5 points on a circle of radius 1.6 centred at (0, 1.05), at 240°, 255°, 270°, 285°, 300° |
86
+ | Spring | open, smooth | k = 0…18, o = k/18, θ = 6π·o: (−1.35 + 2.7·o − 0.32·sin θ, 0.32·cos θ) |
87
+ | 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 θ) |
88
+ | Spiral Out | open, smooth | the Spiral In anchors in reverse order, handles recomputed |
89
+ | 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 |
90
+ | 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 |
91
+ | Figure 8 | closed, smooth | k = 0…7, a = k·π/4: (1.4·sin a, 0.72·sin 2a) |
92
+ | 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 |
93
+ | 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) |
94
+ | Triangle | closed, corners | (0, 0.78), (−1.05, −0.55), (1.05, −0.55) |
95
+ | 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
96
 
61
97
  #### Smoothing / reducing any path — `POST /api/paths/smooth`
62
98
 
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 preset). This endpoint rewrites a point list with fewer, cleaner anchors, and it is the **same code the editor's "Smooth path" button runs**, so an agent-smoothed path is identical to a hand-smoothed one.
99
+ 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
100
 
65
- It is deliberately **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` keyframe value.
101
+ 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
102
 
67
103
  ```json
68
- POST /api/paths/smooth
69
104
  {
70
- "points": [ { "x": 0, "y": 0, "hIn": {...}, "hOut": {...} }, ... ],
105
+ "points": [ { "x": 0, "y": 0, "hIn": { "x": 0, "y": 0 }, "hOut": { "x": 0, "y": 0 }, "manual": true } ],
71
106
  "closed": false,
72
107
  "mode": "fit",
73
108
  "amount": 34,
@@ -77,6 +112,8 @@ POST /api/paths/smooth
77
112
  }
78
113
  ```
79
114
 
115
+ 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).
116
+
80
117
  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
118
 
82
119
  **Pick the mode by what must be preserved:**
@@ -88,9 +125,9 @@ Response: `{ "points": [...], "stats": { "before", "after", "removed", "corners"
88
125
  | `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
126
  | `relax` | Averages out the shake, keeping every anchor | **Unchanged** | Tracked / hand-drawn paths whose count is fine but whose shape wobbles |
90
127
 
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 1920px motion path and a ±0.5 unit-box mask.
92
- - `corner_deg` (default 34) 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.
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.
128
+ - `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.
129
+ - `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.
130
+ - 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
131
  - **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
132
  - 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
133
  - Errors are HTTP 400: fewer than 3 points, more than 5000, an unknown `mode`, or a degenerate (zero-length) path.
@@ -99,7 +136,7 @@ Response: `{ "points": [...], "stats": { "before", "after", "removed", "corners"
99
136
 
100
137
  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
138
 
102
- ```json
139
+ ```jsonc
103
140
  {
104
141
  "textPathEnabled": true,
105
142
  "textPathGrain": "symbols", // 'symbols' (letters) | 'words' — one pose per unit
@@ -116,21 +153,21 @@ A **text** item with a motion path can lay its glyphs out along the curve instea
116
153
  }
117
154
  ```
118
155
 
119
- - Placement is arc-length parameterised (kerning survives corners) and shares `evaluateObjectMotionPath`, so preview, WebCodecs export and `/render-onserver` agree.
156
+ Strip the `//` comments before sending.
157
+
158
+ - 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
159
  - **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) keeps the legacy middle anchor and follows `textAlign` (start/centre/end of the trimmed window). Multi-line text stacks rows along the curve normal at line-height steps.
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).
160
+ - `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.
161
+ - `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
162
  - `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
163
  - 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
164
  - `follow` is NOT required: draw a path, leave `follow: false`, set `textPathEnabled: true`.
126
165
 
127
166
  #### Animated motion path (`motionPathPoints`)
128
167
 
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.
168
+ 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
169
 
133
- ```json
170
+ ```jsonc
134
171
  "propertyKeyframes": {
135
172
  "motionPathPoints": [
136
173
  { "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 +176,13 @@ points, and Follow Path and Text on Path both ride the snapshot at the current f
139
176
  }
140
177
  ```
141
178
 
142
- - **Value:** a JSON string of the points in the WORLD space of `motionPath` (pixels, origin at the comp centre, +y up,
143
- z toward the viewer, handles as offsets, optional `rx/ry/rz`). `closed`, `follow`, `aim` stay on `item.motionPath`.
144
- - **Same point count in every key**, as for mask paths; the shorter list wins if they differ. Dense (per-frame) tracks
145
- use LINEAR keys.
146
- - **Keep `item.motionPath`** with the same points (any key's): the track is only kept while the layer has a path of
147
- 2+ points, and point counts / Text on Path liveness read it.
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.
179
+ - **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`.
180
+ - **Same point count in every key**; the shorter list wins if they differ. Dense (per-frame) tracks use `Linear` keys.
181
+ - **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.
182
+ - **Adding or deleting an anchor** changes every key, split at the same spot of the curve.
183
+ - **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.
184
+ - **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
185
 
156
186
  ### Motion-track follow (`motionTracks` + `motionFollow`)
157
187
 
158
- A video layer the user has motion-tracked in the editor carries the result on the item as `motionTracks: []` — each track `{ id, name, mode, refTime, region, sourceFps, sourceAspect, segments, samples }`, where `samples` rows are `[tSourceSeconds, cx, cy, scale, rotDeg, confidence]` in the clip's **source** seconds, normalised to the source frame (so a track survives trims, splits and speed changes). **The editor's own analysis has no endpoint** — it runs on decoded frames inside the app. Treat a track the USER made as read-only: preserve it verbatim when copying items, and never hand-edit its samples. An agent can, however, compute tracking data itself (OpenCV in Python) and write a NEW track into the project — the sample layout, the coordinate spaces and a worked recipe are in tracking-data.md (GreenLight Dash only).
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
- * * *
188
+ 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).