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.
@@ -10,19 +10,19 @@
10
10
 
11
11
  ## Contents
12
12
 
13
- - [Text caption layers](video-text.md#text-caption-layers)
14
- - [How a text layer is structured](video-text.md#how-a-text-layer-is-structured)
15
- - [Text item fields (defaults from `sanitizeTimelineItem`)](video-text.md#text-item-fields-defaults-from-sanitizetimelineitem)
16
- - [3D Text — real extruded letters (`text3d`)](video-text.md#3d-text--real-extruded-letters-text3d)
17
- - [Dynamic text placeholders (counter, timer, word cycle, scramble, variables, markers, timecode)](video-text.md#dynamic-text-placeholders)
18
- - [Advanced Animations — effects over a moving range of characters, words or lines](video-text.md#advanced-animations--effects-over-a-moving-range-of-characters-words-or-lines)
19
- - [Worked example — add a bottom caption to an existing project](video-text.md#worked-example--add-a-bottom-caption-to-an-existing-project)
13
+ - [Text caption layers](#text-caption-layers)
14
+ - [How a text layer is structured](#how-a-text-layer-is-structured)
15
+ - [Text item fields (defaults the app fills in)](#text-item-fields-defaults-the-app-fills-in): [caption bar look](#caption-bar-look), [caption-bar reveal](#caption-bar-reveal), [Grow Shape](#grow-shape), [shadow](#shadow), [case, decoration and clip mask](#case-decoration-and-clip-mask), [fill style and outline](#fill-style-and-outline), [voice-synced captions](#voice-synced-captions), [Text Randomizer fields](#text-randomizer-fields)
16
+ - [3D Text — real extruded letters (`text3d`)](#3d-text--real-extruded-letters-text3d)
17
+ - [Dynamic text placeholders (counter, timer, word cycle, scramble, variables, markers, timecode)](#dynamic-text-placeholders)
18
+ - [Advanced Animations — effects over a moving range of characters, words or lines](#advanced-animations--effects-over-a-moving-range-of-characters-words-or-lines)
19
+ - [Worked example — add a bottom caption to an existing project](#worked-example--add-a-bottom-caption-to-an-existing-project)
20
20
 
21
21
  ## Text caption layers
22
22
 
23
23
  Use this to add **animated text captions** — e.g. a subtitle that sits at the bottom of a video and reveals word-by-word (or letter-by-letter) with a fade/rise/blur. The motion is **Advanced Animations** (`textAnimators`, its own section below). There is no `/from-clips`-style helper for text; build the text layer by hand into the project's `data` and `PUT` it back.
24
24
 
25
- > ⚠️ **To bake text in, render with `/render-onserver` — not `/render`.** The fast `POST .../render` only composites single-channel `video`/`image` timelines and **ignores** text layers, extra channels, keyframes, and text animations. To get a file with the caption burned in, call **`POST /api/moodboards/{board_id}/video-projects/{project_id}/render-onserver`** (full headless render — bakes in text/voice/animations) after adding the caption. Do **not** call `/render` for a project with captions and report success — the text won't be there. (The desktop editor's **Render Video → Render in Background** does the same thing.)
25
+ > Render captions with **`POST /api/moodboards/{board_id}/video-projects/{project_id}/render-onserver`** (the full headless render bakes text, animations and voice in). The fast `POST …/render` composites only single-channel `video`/`image` timelines and drops text layers. See rendering.md (GreenLight Dash only).
26
26
 
27
27
  ### How a text layer is structured
28
28
 
@@ -36,9 +36,9 @@ A text layer is two things added to the editor `data`:
36
36
 
37
37
  **Channel order = z-order.** Channels composite in array order; the **last** channel in `channels` renders on top. So the video channel comes first, the caption's text channel comes **after** it.
38
38
 
39
- **Timing.** Give the caption channel `"unsnapped": true` and set the item's `freeStart` (timeline seconds where the caption appears). The on-screen duration is `trimEnd - trimStart` (the text clip's natural length is 5s, so `trimEnd` ≤ 5). Example: `freeStart: 1, trimStart: 0, trimEnd: 3` → caption visible from 1s to 4s. An animator's **in** plays at the start of that window, its **out** at the end.
39
+ **Timing.** Give the caption channel `"unsnapped": true` and set the item's `freeStart` (timeline seconds where the caption appears). The on-screen duration is `trimEnd - trimStart` (the text clip's `duration` is its natural length: set it at least as long as the caption shows, with `trimEnd` ≤ it). Example: `freeStart: 1, trimStart: 0, trimEnd: 3` → caption visible from 1s to 4s. An animator's **in** plays at the start of that window, its **out** at the end.
40
40
 
41
- ### Text item fields (defaults from `sanitizeTimelineItem`)
41
+ ### Text item fields (defaults the app fills in)
42
42
 
43
43
  Any field you omit is filled with the default below, so a caption item only needs `id`, `clipId`, the text/style fields you want to change, the position, the timing, and the animation block.
44
44
 
@@ -47,7 +47,7 @@ Any field you omit is filled with the default below, so a caption item only need
47
47
  | `textContent` | `"Type your text"` | The caption string. `\n` makes hard line breaks; long lines auto-wrap at 86% of frame width. |
48
48
  | `textColor` | `"#ffffff"` | Any CSS color. |
49
49
  | `textFontSizeUnit` | `"px"` | `"px"` uses `textFontSize`; `"pct"` uses `textFontSizePct`. |
50
- | `textFontSize` | `96` | Composition pixels (8–1000), used only in `"px"` mode. 96 is title-size at 1080p; captions are commonly 52–72px. A render at another size **with the project's aspect ratio** (720p / 4K of a 1080p project) scales it with the rest of the frame. Only a render at a **different** aspect ratio, which reflows the composition to the new frame, keeps the absolute px (see Output size in `video-rendering.md`). |
50
+ | `textFontSize` | `96` | Composition pixels (8–1000), used only in `"px"` mode. 96 is title-size at 1080p; captions are commonly 52–72px. A render at another size **with the project's aspect ratio** (720p / 4K of a 1080p project) scales it with the rest of the frame. Only a render at a **different** aspect ratio, which reflows the composition to the new frame, keeps the absolute px (see Output size in rendering.md (GreenLight Dash only)). |
51
51
  | `textFontSizePct` | `8.9` | Font size as % of the **composition height** (0.5–100), used only in `"pct"` mode: `5.9` equals about 64px at 1080p. Prefer it for API-authored projects that may be rendered at a different aspect ratio: it follows the reflowed frame there, where px does not. Same-aspect renders scale px and pct alike. |
52
52
  | `textFontFamily` | `"'Helvetica Neue', Helvetica, Arial, sans-serif"` | CSS font stack. |
53
53
  | `textFontWeight` | `400` | 100–900. Captions read better at 700–800. |
@@ -56,32 +56,110 @@ Any field you omit is filled with the default below, so a caption item only need
56
56
  | `textLetterSpacing` | `0` | px (−20–80). |
57
57
  | `textAlign` | `"center"` | `left` \| `center` \| `right`. |
58
58
  | `textWidthUnit` | `"pct"` | `"pct"` uses `textWidthPct`; `"px"` uses `textWidthPx`. |
59
- | `textWidthPct` | `86` | Text box width as % of the **composition width** (5–100). The box is the wrap width and the left/right-align margin. 86 is the legacy default box. |
59
+ | `textWidthPct` | `86` | Text box width as % of the **composition width** (5–100). The box is the wrap width and the left/right-align margin. |
60
60
  | `textWidthPx` | `null` | Text box width in composition pixels (40–20000), used only in `"px"` mode. |
61
61
  | `textNoWrap` | `false` | `true` = white-space nowrap: never auto-wrap, only explicit `\n` line breaks start a new line. The box width is ignored. Overflowing text is NOT clipped — the layer's texture grows to the content (up to 4× the composition width), so a zoom-out or a camera/pan move can travel across a long single line. |
62
62
  | `textWrapAnywhere` | `false` | `true` = a line may break between any two characters (mid-word) to fit the box. Ignored with `textNoWrap`. |
63
63
  | `panX` | `0` | **Horizontal position**, % of the frame WIDTH from center (±50 = the frame edge; no limit, beyond that is off-frame). + = right. |
64
64
  | `panY` | `0` | **Vertical position**, % of the frame HEIGHT from center (±50 = the frame edge; no limit). **+ = down**, − = up. Bottom caption ≈ **`panY: 35`** (≈ `38` sits lower). |
65
65
 
66
- Text extras: `textBgEnabled` + `textBgColor` + `textBgPadding` draw a caption-style background bar behind each text line (padding in px — the "spacing" around the text). `textBgPaddingX` / `textBgPaddingY` (px, 0–200) set the horizontal and vertical spacing separately — the panel's **Spacing H / Spacing V** — and each falls back to `textBgPadding` when omitted or `null`, so setting only the legacy field still moves both axes. The same bar can carry a border: `textBorderEnabled: true` + `textBorderColor` (default `"#ffffff"`, rgba allowed) + `textBorderWidth` (px, 0.5–40, default 2) + `textBorderStyle` (`"solid"` default, `"dashed"`, `"dotted"`). The border is its own toggle — a bar can be outline-only (`textBorderEnabled` without `textBgEnabled`) — and is stroked INSIDE the bar's bounds, so width never grows the bar. `textBgRadius` (px, 0–200) sets the bar's corner radius for both fill and border; omit it (or `null`) for the automatic radius (the padding, capped at 40% of the font size and 40px). The bar can animate instead of appearing instantly: `textBgInType` / `textBgOutType` pick the reveal — `"none"` (default), `"fade"`, `"mask-right"`, `"mask-left"`, `"mask-down"`, `"mask-up"`, `"center-h"`, `"center-v"`. Mask names give the direction the motion travels for In and Out alike (In `"mask-right"` sweeps the bar on left→right, Out `"mask-right"` sweeps it away left→right, so the same value both ways reads as one continuous pass); `center-*` grow/shrink each bar about its middle. `textBgInStyle` / `textBgOutStyle` are `"wipe"` (default — the finished bar is clipped by a rectangle, so the leading edge is flat), `"slide"` (the fill slides behind a mask fixed at the bar's final shape; `mask-*` only) or `"grow"` (**Grow Shape** — the bar's own rounded geometry scales from the anchored edge, so the corner radius is preserved throughout; pick this for pill-shaped bars). `center-*` take `wipe` or `grow`. Grow Shape extends a **seed** sitting at the anchored edge: the seed is `textBgInGrowStart` / `textBgOutGrowStart` (px, 0–600, default 0) when set, otherwise the pill's diameter when the bar has a radius (a circle), otherwise a hairline caret — so the fill really starts at ~0 width. Grow Shape can also give the seed its own appearance: `textBgInGrowAppear` / `textBgOutGrowAppear` — `"none"` (default: the seed is there from the first frame and extends), `"fade"` (alphas in while extending), `"scale-in"` (the seed zooms from a dot **at the start point** — dot → caret line, or dot → circle with a radius — and only then extends along the direction; the two run one after the other, so the total In takes appear-duration + duration) or `"scale-down"` (settles from 150% about the start point while fading in) — with `textBgInGrowAppearDuration` / `textBgOutGrowAppearDuration` (s, 0.05–4, default 0.4) and `textBgInGrowAppearEasing` / `textBgOutGrowAppearEasing` (easing display values, defaults `"Ease Out"` / `"Ease In"`). Nothing scales about the moving box centre, so the shape never drifts. Out mirrors everything (retract to the seed, then the seed zooms away). Ignored unless the slot's style is `"grow"`. `textBgInDuration` / `textBgOutDuration` are seconds (0.05–4, defaults 0.5 / 0.4); In plays over the item's first seconds, Out over its last. `textBgInDelay` (s, 0–4, default 0) holds the bar off-screen for that long before the In reveal starts — use it when the bar should land after the words have begun animating. The reveal (and the delay) applies to the border-only bar too. `textBgInEasing` / `textBgOutEasing` take an easing **display value** such as `"Ease Out"`, `"Expo Out"`, `"Back Out"` — the same values as `transitionInEasing` (defaults `"Ease Out"` in, `"Ease In"` out). Also: `textShadowX`/`textShadowY`/`textShadowBlur`/`textShadowColor` add a text shadow (defaults 0 / `rgba(0,0,0,0.75)`; off while offset and blur are all 0), with `textShadowSpread` (px, 0–50, default 0) thickening it before the blur. Set them only when the user asks for a text shadow ([video-design.md](video-design.md#hierarchy)). `textLetterCase` re-cases the rendered glyphs without touching `textContent` — `"none"` (default), `"uppercase"`, `"lowercase"` or `"capitalize"`, so a caption can shout without the stored text shouting. `textDecoration` is `"none"` (default), `"underline"`, `"line-through"` or `"overline"`. `textClipMask: true` turns the layer into a clipping mask so footage below plays inside the letters — see **Clipping masks** in the shapes section.
66
+ #### Caption bar look
67
67
 
68
- **Fill style / outline type.** `textFillMode` is `"fill"` (default, solid glyphs), `"outline"` (hollow glyphs — the glyph boundary only) or `"both"`. `textOutlineWidth` is the stroke width in px on the text canvas (0–60, default 2; outline modes never go below a 0.5px hairline). `textOutlineColor` is optional — **omit it and the outline uses `textColor`**, so switching mode never silently changes the layer's colour; set it for a contrasting rim in `"both"` mode. Gradients work on the outline exactly as they do on the fill.
68
+ A caption-style bar behind each text line. All lengths are composition px.
69
69
 
70
- Use `textFillMode: "outline"` when putting **Running Glow** on type (see the Layer effects section): the glow draws on the glyph boundary, and a filled glyph swamps it, whereas a hollow glyph is nothing but boundary. A very thin outline gives the effect little to grab — 3px+ at title sizes, or raise `glowRadius`.
70
+ | Field | Default | Notes |
71
+ | --- | --- | --- |
72
+ | `textBgEnabled` | `false` | Draws the bar's fill. |
73
+ | `textBgColor` | `"rgba(0,0,0,0.6)"` | Any CSS colour. |
74
+ | `textBgPadding` | `16` | px (0–200): the spacing around the text on both axes. |
75
+ | `textBgPaddingX` / `textBgPaddingY` | `null` | px (0–200): the horizontal and vertical spacing on their own. `null` or omitted falls back to `textBgPadding`. |
76
+ | `textBgRadius` | `null` | px (0–200): the corner radius of fill and border. `null` or omitted = automatic: the padding, capped at 40 % of the font size and 40 px. |
77
+ | `textBorderEnabled` | `false` | Strokes a border on the bar. It is set apart from the fill, so a bar can be outline-only (`textBorderEnabled` without `textBgEnabled`). The stroke sits inside the bar's bounds, so its width never grows the bar. |
78
+ | `textBorderColor` | `"#ffffff"` | Any CSS colour, rgba allowed. |
79
+ | `textBorderWidth` | `2` | px (0.5–40). |
80
+ | `textBorderStyle` | `"solid"` | `solid` \| `dashed` \| `dotted`. |
81
+
82
+ #### Caption-bar reveal
83
+
84
+ Animate the bar (fill or border-only) in and out, so it does not pop in fully drawn while the words are still
85
+ animating. In plays over the item's first seconds, Out over its last.
86
+
87
+ | Field | Default | Notes |
88
+ | --- | --- | --- |
89
+ | `textBgInType` / `textBgOutType` | `"none"` | `none` \| `fade` \| `mask-right` \| `mask-left` \| `mask-down` \| `mask-up` \| `center-h` \| `center-v`. A mask name is the direction the motion travels, for In and Out alike: In `mask-right` sweeps the bar on left→right and Out `mask-right` sweeps it away left→right, so the same value both ways reads as one continuous pass. `center-*` grow or shrink each bar about its middle. |
90
+ | `textBgInStyle` / `textBgOutStyle` | `"wipe"` | `wipe`: the finished bar is clipped by a rectangle, so the leading edge is flat. `slide`: the fill slides behind a mask fixed at the bar's final shape (`mask-*` only). `grow` ([Grow Shape](#grow-shape)): the bar's own rounded shape scales from the anchored edge and keeps its corner radius; pick it for pill-shaped bars. `center-*` take `wipe` or `grow`. |
91
+ | `textBgInDuration` / `textBgOutDuration` | `0.5` / `0.4` | s (0.05–4). |
92
+ | `textBgInDelay` | `0` | s (0–4): keeps the bar hidden this long before the In starts. Use it when the bar should land after the words have begun animating. There is no Out delay. |
93
+ | `textBgInEasing` / `textBgOutEasing` | `"Ease Out"` / `"Ease In"` | An easing display value, the same values as `transitionInEasing` (`"Expo Out"`, `"Back Out"`…). |
94
+
95
+ #### Grow Shape
96
+
97
+ With style `grow` the bar extends from a **seed** at the anchored edge, so the fill really starts at about zero
98
+ width. Nothing scales about the moving box centre, so the shape never drifts. Out mirrors In: the bar retracts to the
99
+ seed, then the seed goes. These fields are ignored unless the slot's style is `grow`.
100
+
101
+ | Field | Default | Notes |
102
+ | --- | --- | --- |
103
+ | `textBgInGrowStart` / `textBgOutGrowStart` | `0` | px (0–600): the seed's size. At 0 the seed is the pill's diameter (a circle) when the bar has a radius, otherwise a hairline caret. |
104
+ | `textBgInGrowAppear` / `textBgOutGrowAppear` | `"none"` | How the seed appears. `none`: it is there from the first frame and extends. `fade`: it fades in while extending. `scale-in`: it zooms from a dot at the start point (to a caret line, or to a circle with a radius) and only then extends, so the In takes the appear duration plus the duration. `scale-down`: it settles from 150 % about the start point while fading in. |
105
+ | `textBgInGrowAppearDuration` / `textBgOutGrowAppearDuration` | `0.4` | s (0.05–4). |
106
+ | `textBgInGrowAppearEasing` / `textBgOutGrowAppearEasing` | `"Ease Out"` / `"Ease In"` | Easing display values. |
107
+
108
+ #### Shadow
71
109
 
72
- Field groups for the look around the text (the motion itself is `textAnimators`):
110
+ Set these only when the user asks for a text shadow: a shadow under type reads as a stock default
111
+ ([video-design.md](video-design.md#hierarchy)).
73
112
 
74
- | Group | Fields |
75
- | --- | --- |
76
- | Caption bar look | `textBgEnabled` + `textBgColor` (fill), `textBgPadding` or per-axis `textBgPaddingX` / `textBgPaddingY` (px), `textBorderEnabled` + `textBorderColor` + `textBorderWidth` (px) + `textBorderStyle` (`solid`\|`dashed`\|`dotted`), `textBgRadius` (px, omit for auto) — full semantics under **Text extras** above |
77
- | Caption-bar reveal (needs `textBgEnabled` or `textBorderEnabled`) | `textBgInType` / `textBgOutType` (`none`\|`fade`\|`mask-right`\|`mask-left`\|`mask-down`\|`mask-up`\|`center-h`\|`center-v`), `textBgInStyle` / `textBgOutStyle` (`wipe`\|`slide`\|`grow` — grow keeps the corner radius while the bar scales), `textBgInGrowStart` / `textBgOutGrowStart` (px, grow only — start size), `textBgInGrowAppear` / `textBgOutGrowAppear` (`none`\|`fade`\|`scale-in`\|`scale-down`, grow only) + `…GrowAppearDuration` (s) + `…GrowAppearEasing`, `textBgInDelay` (s, 0–4, default 0 — wait before the In starts), `textBgInDuration` / `textBgOutDuration` (s, 0.05–4, defaults 0.5/0.4), `textBgInEasing` / `textBgOutEasing` (easing **display values** like `"Expo Out"`) — animates the background bar in/out so it doesn't pop in while the words are still animating. Full semantics under **Text extras** above |
78
- | Text Randomizer (static per-unit styling, seeded) | `textRandomEnabled` (bool), `textRandomScope` (`words`\|`lines`\|`symbols` — which units get their own style), `textRandomDist` (`uniform`\|`gaussian`\|`nth`\|`wave` — how the variation is distributed across units; `nth` accents every `textRandomNth`-th unit (2–12), `wave` rolls a sine across the text), `textRandomSeed` (0–99999 — same seed = same look; change to reshuffle), `textRandomSizeEnabled` + `textRandomSizeMin`/`textRandomSizeMax` (% of the base font size, 20–300, defaults 80/145), `textRandomColorEnabled` + `textRandomColors` (array of hex/rgba, any count, default `["#33efab"]`) + `textRandomCoverage` (0–100 % — the share of units that get a colour; the rest keep `textColor`), `textRandomWeightEnabled` + `textRandomWeights` (array from 100–900, e.g. `[300, 700]` — each unit draws one), `textRandomItalicEnabled` + `textRandomItalicMode` (`mix`\|`all`) + `textRandomItalicShare` (0–100 % — `mix` only) |
113
+ | Field | Default | Notes |
114
+ | --- | --- | --- |
115
+ | `textShadowX` / `textShadowY` | `0` | px (−200–200): the offset. |
116
+ | `textShadowBlur` | `0` | px (0–200). The shadow is off while the offset and the blur are all 0. |
117
+ | `textShadowColor` | `"rgba(0,0,0,0.75)"` | Any CSS colour. |
118
+ | `textShadowSpread` | `0` | px (0–50): thickens the shadow before the blur. |
119
+
120
+ #### Case, decoration and clip mask
121
+
122
+ | Field | Default | Notes |
123
+ | --- | --- | --- |
124
+ | `textLetterCase` | `"none"` | `none` \| `uppercase` \| `lowercase` \| `capitalize`. Re-cases the drawn glyphs without touching `textContent`, so a caption can shout while the stored text does not. |
125
+ | `textDecoration` | `"none"` | `none` \| `underline` \| `line-through` \| `overline`. |
126
+ | `textClipMask` | `false` | `true` turns the layer into a clipping mask, so footage below plays inside the letters. See the **Clipping masks** paragraph in [video-shapes.md](video-shapes.md#shape-layers-mediatype-shape). |
127
+
128
+ #### Fill style and outline
79
129
 
80
- **Voice-synced captions.** Caption items produced by the AI Captions, per-take Captions and AI Shorts flows carry `textWordTimings` — an array with **exactly one `{ "startMs": int, "endMs": int }` entry per whitespace word of `textContent`**, in item-source-local milliseconds (0 = the item's untrimmed start). An animator with `range.drive: "voice"` gives every word (or letter, or line) its turn when it is spoken — see **Drive by Time / Voice** under Advanced Animations. When `textWordTimings` is missing or its length no longer matches the word count of `textContent` (the caption text was edited), a voice animator runs as `time`; the timings stay stored, so restoring the text restores the sync. Attach your own `textWordTimings` (item-local ms, exactly one entry per whitespace word) to any caption item to get the same behaviour.
130
+ `textFillMode` is `"fill"` (default, solid glyphs), `"outline"` (hollow glyphs — the glyph boundary only) or `"both"`. `textOutlineWidth` is the stroke width in px on the text canvas (0–60, default 2; outline modes never go below a 0.5px hairline). `textOutlineColor` is optional — **omit it and the outline uses `textColor`**, so switching mode never silently changes the layer's colour; set it for a contrasting rim in `"both"` mode. Gradients work on the outline exactly as they do on the fill.
81
131
 
82
- **Voice link (`textVoiceSource`).** A text layer can name the audio / video / voice layer whose speech times its words: `"textVoiceSource": { "itemId": "<that timeline item's id>", "clipId": "<its clip id>" }` (the app also stores `text`, `at`, `srcAt`, `matched`, `total` — what the last sync saw). The editor's **Advanced Animations ▸ Voice** panel derives `textWordTimings` from it: the layer's words are matched against the source's transcript (`item.aiData.transcribe.words`, else `clip.transcript.words`, seconds) where both layers sit on the timeline (trim and speed curve included), loosely enough that a corrected spelling, an inserted or a dropped word still lands; unmatched words are placed between their matched neighbours. **Resync** redoes it after the text is edited or either layer moves, **Resync all** for every layer on the same voice, **Move to its words** places a layer whose words are spoken elsewhere. **Link all on this track** gives every other text layer of the same track the same voice (captions made before links existed). Captions made by AI Captions and the Transcribe panel are linked when created. When you write a caption yourself, either compute `textWordTimings` directly, or set `textVoiceSource` (`itemId` + `clipId`) and tell the user to press Resync in the Voice panel — the link alone does not re-time anything until a sync runs.
132
+ Use `textFillMode: "outline"` when putting **Running Glow** on type ([effects.md](video-effects.md#running-glow-entry)): the glow draws on the glyph boundary, and a filled glyph swamps it, whereas a hollow glyph is nothing but boundary. A very thin outline gives the effect little to grab — 3px+ at title sizes, or raise `glowRadius`.
83
133
 
84
- **Text Randomizer.** The `textRandom*` fields give each word/letter/line its own size, colour, font weight and italic — a deterministic, seeded "ransom note / editorial mix" look. It works on static text and composes with Advanced Animations: units are styled first, the animators then run on the styled result. The distribution value drives size, weight **and** colour eligibility together (the top `textRandomCoverage` % of units get a colour), so with `dist: "nth"` the accented units are the same across all properties. Ready-to-paste example (three-colour ransom-note letters):
134
+ #### Voice-synced captions
135
+
136
+ Caption items produced by the AI Captions, per-take Captions and AI Shorts flows carry `textWordTimings` — an array with **exactly one `{ "startMs": int, "endMs": int }` entry per whitespace word of `textContent`**, in item-source-local milliseconds (0 = the item's untrimmed start). An animator with `range.drive: "voice"` gives every word (or letter, or line) its turn when it is spoken — see **Drive by Time / Voice** under Advanced Animations. When `textWordTimings` is missing or its length no longer matches the word count of `textContent` (the caption text was edited), a voice animator runs as `time`; the timings stay stored, so restoring the text restores the sync. Attach your own `textWordTimings` (item-local ms, exactly one entry per whitespace word) to any caption item to get the same behaviour.
137
+
138
+ **Voice link (`textVoiceSource`).** A text layer can name the audio / video / voice layer whose speech times its words: `"textVoiceSource": { "itemId": "<that timeline item's id>", "clipId": "<its clip id>" }` (the app also stores `text`, `at`, `srcAt`, `matched`, `total` — what the last sync saw). A voice sync in the editor derives `textWordTimings` from it: the layer's words are matched against the source's transcript (`item.aiData.transcribe.words`, else `clip.transcript.words`, seconds) where both layers sit on the timeline (trim and speed curve included), loosely enough that a corrected spelling, an inserted or a dropped word still lands; unmatched words are placed between their matched neighbours. A sync runs again when asked, after the text is edited or either layer moves, for one layer or for every layer on the same voice; it can also move a layer whose words are spoken elsewhere onto them, and give every other text layer of the same track the same voice. Captions made by AI Captions and Transcribe are linked when created. When you write a caption yourself, either compute `textWordTimings` directly, or set `textVoiceSource` (`itemId` + `clipId`) and ask the user to run the voice sync in the editor — the link alone does not re-time anything until a sync runs.
139
+
140
+ #### Text Randomizer fields
141
+
142
+ The `textRandom*` fields give each word/letter/line its own size, colour, font weight and italic — a deterministic, seeded "ransom note / editorial mix" look. It works on static text and composes with Advanced Animations: units are styled first, the animators then run on the styled result. The distribution value drives size, weight **and** colour eligibility together (the top `textRandomCoverage` % of units get a colour), so with `dist: "nth"` the accented units are the same across all properties.
143
+
144
+ | Field | Default | Notes |
145
+ | --- | --- | --- |
146
+ | `textRandomEnabled` | `false` | Turns the Randomizer on. Size, colour and weight then vary unless their own `…Enabled` is set `false`. |
147
+ | `textRandomScope` | `"words"` | `words` \| `lines` \| `symbols`: which units get their own style. |
148
+ | `textRandomDist` | `"uniform"` | `uniform` \| `gaussian` \| `nth` \| `wave`: how the variation is spread across units. `nth` accents every `textRandomNth`-th unit; `wave` rolls a sine across the text. |
149
+ | `textRandomNth` | `3` | 2–12, `nth` only. |
150
+ | `textRandomSeed` | `7` | 0–99999: the same seed gives the same look; change it to reshuffle. |
151
+ | `textRandomSizeEnabled` | `true` | Varies the size between `textRandomSizeMin` and `textRandomSizeMax`. |
152
+ | `textRandomSizeMin` / `textRandomSizeMax` | `80` / `145` | % of the base font size (20–300). |
153
+ | `textRandomColorEnabled` | `true` | Gives a share of the units a colour from `textRandomColors`. |
154
+ | `textRandomColors` | `["#33efab"]` | Up to 12 hex or rgba strings. |
155
+ | `textRandomCoverage` | `35` | 0–100 %: the share of units that get a colour; the rest keep `textColor`. |
156
+ | `textRandomWeightEnabled` | `true` | Each unit draws a weight from `textRandomWeights`. |
157
+ | `textRandomWeights` | `[300, 700]` | Up to 9 weights from 100–900. |
158
+ | `textRandomItalicEnabled` | `false` | Sets units in italic. |
159
+ | `textRandomItalicMode` | `"mix"` | `mix` (a share of the units) \| `all`. |
160
+ | `textRandomItalicShare` | `30` | 0–100 %, `mix` only. |
161
+
162
+ Ready-to-paste example (three-colour ransom-note letters, the look of [Classic Ransom Note](video-text-presets.md#classic-ransom-note)):
85
163
 
86
164
  ```json
87
165
  {
@@ -116,9 +194,10 @@ layer transform. **Rotate 3D in a Text Animator turns the letters for real**, sh
116
194
  }
117
195
  ```
118
196
 
119
- - **Lighting.** With a Scene Rig (3D Rig layer) the letters join its shared world: its lights and environment light
120
- them and they cast and receive its shadows. Without one, `light` picks a studio set and `envIntensity` the
121
- reflections.
197
+ - **Lighting.** With a Scene Rig (3D Rig layer) its lights and environment light the letters. With
198
+ `renderer: "classic"` they also cast and receive the Rig's shadows; in a 3D Scene (any other Rig) the world draws
199
+ the backdrop and the models, and the letters cast no shadow on them. Without a Rig, `light` picks a studio set and
200
+ `envIntensity` the reflections.
122
201
  - **Fonts.** Each letter is traced from the font the layer really draws with, so Google, uploaded and system fonts
123
202
  and any weight work. The face is the 2D glyph's exact outline; the bevel swells the sides outward.
124
203
  - **Not in 3D:** the caption bar, outline stroke, text shadow and glow, blur and decorations are 2D paint and are not
@@ -134,14 +213,14 @@ layer transform. **Rotate 3D in a Text Animator turns the letters for real**, sh
134
213
  |---|---|
135
214
  | `{{{COUNTER:0-100}}}` | A number animating between two values. Format follows the typed range: `1,000-25,000` separators · `0.0-99.9` decimals (max 4) · `00-59` zero-padded · `100-0` counts down · `-50..50` negatives (`..`, `to`, `–` also separate) · `250` alone = 0 → 250. |
136
215
  | `{{{TIMER:05:00-00:00}}}` | A clock: `ss`, `mm:ss` or `h:mm:ss`, optional `.s` decimals. One value alone (`05:00`) counts down to zero. Runs in real time by default; same styles as COUNTER. |
137
- | `{{{CYCLE:fast\|simple\|yours}}}` | Words take turns: each holds `hold` s, then rolls/fades/blurs to the next over `duration` s. |
216
+ | `{{{CYCLE:fast\|simple\|yours}}}` | Words or phrases take turns: each holds `hold` s, then rolls / fades / blurs / **morphs** to the next. Morph: the letters both share travel from their place in one to their place in the next; the others leave and arrive. |
138
217
  | `{{{SCRAMBLE:Access granted}}}` | Random characters decoding into the text one by one; keeps its final width the whole time. |
139
218
  | `{{{VAR:product}}}` | A PROJECT variable — `data.textVariables: [{ "name": "product", "value": "GreenLight" }]` at the top level of the project data (names unique ignoring case). Expands to plain text, so it wraps and animates like typed words. |
140
219
  | `{{{MARKER}}}` / `{{{MARKER:Intro}}}` | The name of the last timeline marker (`data.markers`) the playhead passed; the argument is shown before the first one (and for an unnamed marker). Plain text, like VAR. |
141
220
  | `{{{TIMECODE}}}` | The timeline time `hh:mm:ss:ff` in equal digit cells. Arguments: `clip` (time since the layer started) and/or a format `hh:mm:ss`, `mm:ss`, `mm:ss:ff`, `ss:ff`, `ss` — e.g. `{{{TIMECODE:clip mm:ss}}}`. |
142
221
  | `{{{FRAME}}}` / `{{{FRAME:clip}}}` | The frame number (project, or from the layer's start). |
143
222
 
144
- **Each placeholder's own settings go INSIDE its token**, after `;` as `key=value` pairs — write only what differs from the default (the inspector's PLACEHOLDERS cards write exactly this, and remove an option when it is reset). Settings therefore travel with the text:
223
+ **Each placeholder's own settings go INSIDE its token**, after `;` as `key=value` pairs — write only what differs from the default (the editor writes exactly this, and removes an option set back to its default). Settings therefore travel with the text:
145
224
 
146
225
  ```
147
226
  Orders {{{COUNTER:0-2,480; style=roll; duration=2.5; easing=expo_out}}} in {{{TIMER:05:00; duration=10}}}
@@ -152,26 +231,48 @@ Orders {{{COUNTER:0-2,480; style=roll; duration=2.5; easing=expo_out}}} in {{{TI
152
231
  |---|---|
153
232
  | COUNTER | `style` odometer\|roll\|count (odometer) · `start` s (0) · `duration` s (1.8) · `easing` a TransitionEasing key in lowercase, e.g. `cubic_out` (cubic_out) · `width` fixed\|fit (fixed) · `digits` tabular\|natural, Count only (tabular) · `turns` 0–10 (3) · `lands` left\|right\|together (left) · `cascade` 0–90 % (45) · `direction` auto\|up\|down (auto) · `blur` 0–100 % (35) · `kf` a track id |
154
233
  | TIMER | the same keys; defaults are a real clock: `style` count, `duration` = the timer's own span (05:00 → 300 s), `easing` linear |
155
- | CYCLE | `style` roll\|fade\|blur (roll) · `start` (0) · `hold` s per word (1.2) · `duration` transition s (0.45) · `easing` (cubic_out) · `width` fit\|fixed (fit) · `direction` up\|down, roll only (up) · `loop` on\|off (on) |
234
+ | CYCLE | `style` roll\|fade\|blur\|morph (roll) · `start` (0) · `hold` s per state (1.2) · `duration` transition s (0.45; morph = each letter's travel, 0.8) · `easing` (cubic_out; morph expo_in_out) · `width` fit\|fixed (fit) · `direction` up\|down: roll, and a morph's rise/arc (up) · `loop` on\|off (on) · morph only: `match` order\|any (order) · `case` ignore\|keep (ignore) · `stagger` s between letters 0–2 (0.03) · `arc` 0–1 (0) · `leave` mask\|fade\|blur\|scale (fade) · `order` left\|center\|all (left) |
156
235
  | SCRAMBLE | `start` (0) · `duration` (1.2) · `chars` symbols\|letters\|digits\|binary\|blocks (symbols) · `order` ltr\|rtl\|random (ltr) · `flicker` glyph changes/s (18) |
157
236
  | TIMECODE / FRAME | no options — `clip` and the format are the token body (`{{{TIMECODE:clip mm:ss}}}`) |
158
237
 
159
238
  - Styles: `odometer` — each digit column rolls on its own from start digit to end digit, `turns` extra spins on the rightmost column, columns landing in a cascade; `roll` — a mechanical odometer (the last digit spins fastest, carries turn the next column; a timer's seconds-tens wheel has six digits); `count` — the digits just change.
160
239
  - Timing is seconds on the item's **source** clock (`trimStart` + clip time). `width: fixed` reserves room for the widest value (nothing around it moves); `fit` closes the gap so neighbouring words slide.
161
240
  - Keyframed progress: `kf=<id>` makes the placeholder follow `propertyKeyframes["ph:<id>"]` (values `"0"`–`"100"`, % of the way from start to end) instead of start/duration/easing — e.g. `{{{COUNTER:0-100; kf=1}}}` with a `ph:1` track. Each placeholder can have its own.
162
- - Unknown or malformed options are ignored (the token still renders). The layer fields `textCounter*` / `textCycle*` / `textScramble*` and the layer-wide `textCounterProgress` track are legacy (10.10.32–36) — still honoured as the base for tokens without options, but new work should use token options.
241
+ - Unknown or malformed options are ignored (the token still renders). The layer fields `textCounter*` / `textCycle*` / `textScramble*` and the layer-wide `textCounterProgress` track are read for tokens without options; write token options instead.
242
+
243
+ **Text Morph** (`style=morph`): one text turns into the next. `match=order` pairs letters that keep their order; `any` lets any letter go anywhere (anagrams: `Dash|Hands; match=any; order=center; arc=0.7; leave=scale`). Pairs that differ only in case (`i`↔`I`) crossfade on the way unless `case=keep`. Letters leave in the direction (`up`) and arrive from the other side. `leave`: `mask` slides them out of a line-high mask; `fade` rises 0.4 em and fades; `blur` blurs out; `scale` shrinks away. Each step lasts `duration + stagger × (letters involved − 1)`, then the next state holds `hold` s. A state at rest looks exactly like the typed text; letters travel around the line's alignment (left / centre / right edge).
244
+
245
+ ```
246
+ {{{CYCLE:Give it a studio|GIVIT Studio; style=morph; loop=off}}}
247
+ Any {{{CYCLE:model|bot|agent; style=morph; leave=mask}}}
248
+ ```
249
+
250
+ **Line states** (`textLineStates`): every line of `textContent` is one state of a cycle, no token needed. The first line shows, then turns into the next, and so on. The field takes the CYCLE options as JSON (`easing` = a TransitionEasing key in UPPERCASE, `loop` a boolean). Only `style` is required; omit defaults; `null` / absent = off. Blank lines are skipped. A placeholder inside a line shows its resting value.
251
+
252
+ ```json
253
+ { "textContent": "Connect\nGenerate\nTalk\nPost",
254
+ "textLineStates": { "style": "morph", "hold": 0.9, "leave": "mask", "stagger": 0.026 } }
255
+ ```
256
+
257
+ ```json
258
+ { "textContent": "Fast\nSimple\nYours", "textLineStates": { "style": "roll", "hold": 1, "loop": false } }
259
+ ```
260
+
261
+ The whole cycle is one unit of the text: Text Animators, the randomizer and text on path treat it as one character. 3D Text films the letters; there `mask` and `blur` become fades. A state never wraps onto a second line.
163
262
 
164
263
  ### Advanced Animations — effects over a moving range of characters, words or lines
165
264
 
166
265
  **Start from a preset recipe** before building animators from scratch: the app's presets are ready JSON, tuned in the app.
167
- - Energetic text (a title, hook, promo or social line, anything punchy, bouncy or kinetic): [video-text-kinetic.md](video-text-kinetic.md) — 21 in-and-out animations (Swing Pop and its family).
168
- - Everything else: [video-text-presets.md](video-text-presets.md) — 46 recipes. Sweep Reveal (typing under a colour band), Type on Shape (text typed onto a pill, card or bar that grows with it), Emphasis (one highlight pass), Loop (neon, rainbow, float, flicker), Characters (decode, binary, glitch) and Voice (caption karaoke on `textWordTimings`).
266
+ - Energetic text (a title, hook, promo or social line, anything punchy, bouncy or kinetic): [video-text-kinetic.md](video-text-kinetic.md) — 22 in-and-out animations (Swing Pop and its family).
267
+ - Everything else: [video-text-presets.md](video-text-presets.md) — 54 recipes plus the app's 29 Classic ones. Sweep Reveal (typing under a colour band), Type on Shape (text typed onto a pill, card or bar that grows with it), Depth (words and letters moving in real depth under one camera: Rack Focus, Stand Up, Strata, Spotlight, Ripple), Emphasis (one highlight pass), Loop (neon, rainbow, float, flicker), Characters (decode, binary, glitch), Voice (caption karaoke on `textWordTimings`) and Classic (calm word or letter reveals, highlights, caption bars and randomizer looks, e.g. Fade Up, Rise (Mask), Typewriter, Lower Third).
169
268
 
170
- **Pace:** an entrance, an exit or a one-time pass takes at least 0.8–1 s by default, from the first unit starting to the last one settling. On Time that span is `(units − 1) × stagger + duration`. Go faster only when the edit needs it. The rule and its exceptions are in [video-projects.md](video-projects.md#text-animation-pace).
269
+ **Pace:** entrances, exits and passes take ≥ 0.8 s by default; recipes keep their tuned timing. The rule, its formula and exceptions: [video-design.md](video-design.md#text-animation-pace).
270
+
271
+ `GET /api/video-editor/capabilities` does not list Text Animator effects or presets: this page and the two preset pages are their schema.
171
272
 
172
273
  The After Effects text-animator model on a text layer (stored as `textAnimators`). `item.textAnimators` is a list of animators; each one **selects** units of the text with a range (plus an optional wiggle) and applies its **effects** to them. Every effect value is what a unit gets at 100 % selection; the unit's selection amount (−1…1) scales it. What moves the selection is the range's `drive`: `range` (default) = Start / End / Offset, animated with `txa:` keyframe tracks or with `flow` for an endless keyframe-free travel; `time` = every unit takes its turn in seconds (a stagger, with In / Out and a highlight clock); `voice` = every unit takes its turn when it is spoken (captions). Renders identically in preview, `/render-onserver` and stills.
173
274
 
174
- `textAnimators` is the only text animation. Never write `textSplit*`, `splitIn*`, `splitOut*`, `textHighlight*` or `textCaret*` fields, or `textRevealProgress` / `textHighlightColor` tracks: the app ignores them. A stored project that still carries them is converted into `textAnimators` when the app loads it, so a `GET` of a project nobody has opened since may still show them.
275
+ `textAnimators` is the only text animation. Never write `textSplit*`, `splitIn*`, `splitOut*`, `textHighlight*` or `textCaret*` fields, or `textRevealProgress` / `textHighlightColor` tracks: the app ignores them. A stored project that carries them is converted into `textAnimators` when the app loads it, so a `GET` of a project the app has not loaded may show them.
175
276
 
176
277
  ```jsonc
177
278
  "textAnimators": [{
@@ -189,6 +290,8 @@ The After Effects text-animator model on a text layer (stored as `textAnimators`
189
290
  }],
190
291
  "textAnimatorPivotX": 0, // −100…100 % of the unit's box from its centre (100 = right edge)
191
292
  "textAnimatorPivotY": 0 // −100…100 % (100 = bottom)
293
+ "textAnimatorCamera": "layer", // who films Position z and Rotate 3D: "unit" (default, omit) | "layer" | "project"
294
+ "textAnimatorPerspective": 1300 // "layer" only: the camera's distance in px; omit / 0 = the composition camera's (2166 px at 1080p)
192
295
  ```
193
296
 
194
297
  Rules the sanitizer enforces (anything else is dropped or clamped on load): `aid`/`pid` match `[A-Za-z0-9_-]{1,24}` and are unique; at most 8 animators and 12 effects per animator; unknown effect `type`s are dropped; every field takes its default when missing. Omit `textAnimators` (or leave it empty) for a layer without animators — the three layer fields are written only alongside a non-empty list. All px values are composition px.
@@ -209,8 +312,8 @@ Rules the sanitizer enforces (anything else is dropped or clamped on load): `aid
209
312
  | `easeHigh`, `easeLow` | −100…100 % (0) | Non-square shapes: bend the profile near 1 / near 0 (+ lingers, − snaps). |
210
313
  | `amount` | −100…100 % (100) | Scales the selection; negative pushes every effect the other way. |
211
314
  | `randomize`, `seed` | bool (false), 0–9999 (1) | Shuffles which unit sits where in the span (deterministic per seed). |
212
- | `flow` | −400…400 (0) | Offset added per second (%/s or units/s) — continuous travel without keyframes. |
213
- | `loop` | bool (false) | The span wraps around the ends (pair with `flow` for an endless pass). Under `time`: the cascade repeats. |
315
+ | `flow` | −400…400 (0) | Offset added per second (%/s or units/s) — continuous travel without keyframes. Without `loop`, a span starting at a negative offset flows in and stops at 0; start at 0 to travel off the text. |
316
+ | `loop` | bool (false) | The span wraps around the ends (pair with `flow` for an endless pass). Under `time`: the cascade repeats (with `in`/`active`/`stay`, not `out`/`inOut`, nor while voice-synced). |
214
317
 
215
318
  **Drive by Time / Voice.** Every unit gets its own clock instead of a moving span; no keyframes, and the seconds are anchored to the layer's start (In) and end (Out), so trimming a layer never stretches the motion.
216
319
 
@@ -221,7 +324,7 @@ Rules the sanitizer enforces (anything else is dropped or clamped on load): `aid
221
324
  | `delay` | −60…600 s (0) | In: after the layer starts. Out: before it ends. Voice: after the word is spoken (negative = before). |
222
325
  | `stagger` | 0…10 s (0.06) | Time: between one unit's turn and the next. |
223
326
  | `duration` | 0…20 s (0.6) | How long each unit takes (0 = instant). For `active`/`stay` it is the fade-in. |
224
- | `curve` | the app's easing names (`Ease Out`) | Each unit's own easing: `Linear`, `Ease In/Out`, `Smooth`, `Smooth Glide`, `Glide Easing Stop`, `Sine/Expo/Back In/Out/Smooth`, `Cubic In/Out`, `Power`, `Punch`, `Apple Out`, `Bounce Out`, `Elastic`, `Overshoot`, `Smooth Overshoot`, `Impulse`, `Swing`, `Anticipate`, `Natural`, `Slow Down`, `AE In (arrive)`, `AE Smooth`, `AE Out (leave)` (a retired name such as `Power2 Smooth` loads as its replacement). Back, Bounce, Elastic and the Overshoots land each unit past its rest and settle (transforms follow the overshoot, colours clamp). `Anticipate` is an exit: the unit winds back the other way (to −0.38 of the move at 58 %), then snaps through in the last frames. `Custom` plays `bezier`. |
327
+ | `curve` | the app's easing names (`Ease Out`) | Each unit's own easing: `Linear`, `Ease In/Out`, `Smooth`, `Smooth Glide`, `Glide Easing Stop`, `Sine/Expo/Back In/Out/Smooth`, `Cubic In/Out`, `Power`, `Punch`, `Apple Out`, `Bounce Out`, `Elastic`, `Overshoot`, `Smooth Overshoot`, `Impulse`, `Swing`, `Anticipate`, `Natural`, `Slow Down`, `AE In (arrive)`, `AE Smooth`, `AE Out (leave)` (an alias such as `Power2 Smooth` loads as the name it maps to). Back, Bounce, Elastic and the Overshoots land each unit past its rest and settle (transforms follow the overshoot, colours clamp). `Anticipate` is an exit: the unit winds back the other way (to −0.38 of the move at 58 %), then snaps through in the last frames. `Custom` plays `bezier`. |
225
328
  | `bezier` | `[x1, y1, x2, y2]` or null (null) | The `Custom` curve's handles, the same shape as a keyframe's `bezier` (x 0…1, y −3…4; past 0…1 = overshoot). Write it together with `"curve": "Custom"`: a `Custom` without it plays `Ease Out`, and any named `curve` drops it. |
226
329
  | `hold` | 0…600 s (0) | `active`: how long a unit stays lit; 0 = until the next unit's turn (voice: until the next word starts). |
227
330
  | `fadeOut` | 0…20 s (0) | `active`: fade back down after `hold`. |
@@ -236,10 +339,10 @@ Rules the sanitizer enforces (anything else is dropped or clamped on load): `aid
236
339
 
237
340
  | `type` | Fields (default) | How the selection applies |
238
341
  |---|---|---|
239
- | `position` | `x` 0, `y` −80 px | adds value × amount |
342
+ | `position` | `x` 0, `y` −80, `z` 0 px | adds value × amount. `z` = depth, + toward the viewer. Under a shared camera (`textAnimatorCamera` `layer` / `project`) the unit really moves in depth: nearer units grow, all converge to one vanishing point, nearer covers farther, a unit at or past the camera is not drawn. With each unit's own camera (default) `z` scales the unit in place by D / (D − z), D = its Rotate 3D `perspective`, else `textAnimatorPerspective`, else the composition camera's distance |
240
343
  | `scale` | `scale` 160, `x` 100, `y` 100 % | 100 % → value, multiplies |
241
344
  | `rotation` | `angle` 45° | adds |
242
- | `rotate3d` | `x` 90, `y` 0°, `perspective` 800 px | adds; real perspective, a camera `perspective` px in front of the unit (0 = flat). +X tips the top away, +Y the right side |
345
+ | `rotate3d` | `x` 90, `y` 0°, `perspective` 800 px | adds; real perspective. Each unit's own camera (default): a camera `perspective` px in front of the unit (0 = flat); every unit turns like its own card. Under a shared camera `perspective` is ignored and every turn shares that camera's vanishing point. +X tips the top away, +Y the right side |
243
346
  | `skew` | `skew` 25°, `axis` 0° | adds |
244
347
  | `tracking` | `amount` 40 px | adds space around the unit; the row reflows and re-aligns |
245
348
  | `opacity` | `opacity` 0 % | 100 % → value, multiplies |
@@ -255,64 +358,53 @@ Rules the sanitizer enforces (anything else is dropped or clamped on load): `aid
255
358
  | `scramble` | `charset` `upper`\|`lower`\|`digits`\|`alnum`\|`symbols`\|`binary`\|`blocks`, `rate` 18/s, `seed` 1 | a unit shows random glyphs with probability = its selection |
256
359
  | `charOffset` | `offset` 6 | shifts letters/digits by `round(offset × amount)` through the alphabet |
257
360
 
258
- Colours are `#rrggbb`. On a text layer used as a clip mask (`textClipMask`), `glow`, `shadow` and `box` are skipped (they would become coverage). `box` is not drawn on text on a path.
361
+ `textAnimatorCamera` / `textAnimatorPerspective` are written only while the camera is shared; `"unit"` and a Perspective of 0 are dropped.
362
+
363
+ **Depth and one camera.**
364
+ - `layer` puts one camera in front of the text plate's centre; it rides the layer, like CSS perspective inside a 3D-transformed element.
365
+ - `project` films the letters with the project's Camera layer. A dolly, pan, tilt or the layer's own `rotateX`/`rotateY` gives true parallax between letters at different `z`; with no camera move it equals `layer`.
366
+ - 3D Text (`text3d`) letters move in real depth with `z`; the camera field doesn't apply there.
367
+ - Track: `txa:<aid>:<pid>.z`.
368
+
369
+ ```json
370
+ { "textAnimators": [{ "aid": "a1", "name": "Fly in", "enabled": true,
371
+ "range": { "drive": "time", "animate": "in", "basedOn": "charsNoSpace", "stagger": 0.06, "duration": 1.4, "curve": "Expo Out" },
372
+ "effects": [{ "pid": "p1", "type": "position", "x": 0, "y": 0, "z": -3200 }, { "pid": "p2", "type": "opacity", "opacity": 0 }] }],
373
+ "textAnimatorCamera": "project" }
374
+ ```
375
+
376
+ Colours must be 6-digit `#rrggbb`; `#fff`, `rgba()` or names silently become the effect's default colour. On a text layer used as a clip mask (`textClipMask`), `glow`, `shadow` and `box` are skipped (they would become coverage). `box` is not drawn on text on a path.
259
377
 
260
378
  **Keyframes.** `propertyKeyframes["txa:<aid>:<chan>"]`, the usual `{ position, value, easing }` keys, value in the field's own units:
261
379
  - range: `start`, `end`, `offset`, `amount`, `smooth`, `easeHigh`, `easeLow` (under `time`/`voice` only `amount`)
262
380
  - wiggle: `wig.max`, `wig.min`, `wig.corr`
263
- - effect: `<pid>.<field>` for any numeric effect field except `scramble`'s `rate`/`seed` (e.g. `txa:a1:p2.y`)
381
+ - effect: `<pid>.<field>` for any numeric effect field except `scramble` `rate`/`seed` and `caret` `width`/`blink`/`hideAfter` (e.g. `txa:a1:p2.y`)
264
382
 
265
383
  Rates (`flow`, wiggle `rate`) and seeds are not keyframable. A track whose animator or effect no longer exists is dropped on load.
266
384
 
267
- Worked examples (spread onto a text item; durations assume a 4 s layer):
385
+ **Speed curves.** A text layer's own animation plays on its **speed-mapped clock**, like a video's frames: with `speedPoints` (or a `speed` track) on the layer, the animators, the times they run on (`time`/`voice` delays and durations, Flow, Wiggle, the caret) and the caption-bar reveal follow the curve. So a `txa:` key's `position` is a % of the clip's **source** length (`trimEnd − trimStart`, its 1× length), not of its timeline length; on a flat curve the two are the same. Its other keyframe tracks (position, scale, opacity, colours) stay on the timeline. The editor draws and edits `txa:` keys where they play.
268
386
 
269
- ```jsonc
270
- // Colour sweep: a yellow, slightly raised band travels across the letters once.
271
- "textAnimators": [{ "aid": "a1", "name": "Sweep", "range": { "end": 14, "shape": "smooth" },
272
- "effects": [ { "pid": "p1", "type": "fill", "color": "#ffd84d" }, { "pid": "p2", "type": "position", "x": 0, "y": -16 } ] }],
273
- "propertyKeyframes": { "txa:a1:offset": [ { "position": 5, "value": "-14", "easing": "Smooth" }, { "position": 80, "value": "100", "easing": "Smooth" } ] }
387
+ Worked examples of the mechanisms (spread onto a text item; durations assume a 4 s layer). For a finished look, take the
388
+ app's recipe instead: a band sweeping the letters once is [Colour Sweep](video-text-presets.md#colour-sweep), typing under a
389
+ colour band is [Ember Type](video-text-presets.md#ember-type) and the other Sweep Reveal recipes, an endless neon pass is
390
+ [Neon Flow](video-text-presets.md#neon-flow), a box under each spoken word is [Spoken Box](video-text-presets.md#spoken-box),
391
+ letters typed at speaking pace are [Typed as Spoken](video-text-presets.md#typed-as-spoken), and energetic in-and-out
392
+ titles are in [video-text-kinetic.md](video-text-kinetic.md).
274
393
 
394
+ ```jsonc
275
395
  // Cascade in: letters drop and spin into place left to right (rampUp = everything after the span is still hidden).
276
396
  "textAnimators": [{ "aid": "a1", "name": "Cascade", "range": { "end": 30, "shape": "rampUp" },
277
397
  "effects": [ { "pid": "p1", "type": "position", "x": 0, "y": -140 }, { "pid": "p2", "type": "rotation", "angle": -35 },
278
398
  { "pid": "p3", "type": "opacity", "opacity": 0 } ] }],
279
399
  "propertyKeyframes": { "txa:a1:offset": [ { "position": 0, "value": "-30", "easing": "Smooth" }, { "position": 85, "value": "100", "easing": "Smooth" } ] }
280
400
 
281
- // Type in under a colour band (how the Sweep Reveal presets work; the exact ones are in video-text-presets.md): letters fade in one by one while falling
282
- // into place, and a second animator's band (start 20, end 80, smooth) runs across in a warm colour.
283
- // Offset −80 parks the band before the text and +80 past it, so the text starts and ends in its own colour.
284
- "textAnimators": [
285
- { "aid": "a1", "name": "Fade down", "range": { "end": 8, "shape": "rampUp" },
286
- "effects": [ { "pid": "p1", "type": "position", "x": 0, "y": -30 }, { "pid": "p2", "type": "opacity", "opacity": 0 } ] },
287
- { "aid": "a2", "name": "Ember band", "range": { "start": 20, "end": 80, "shape": "smooth" },
288
- "effects": [ { "pid": "p3", "type": "fill", "color": "#e8892b" } ] }],
289
- "propertyKeyframes": {
290
- "txa:a1:offset": [ { "position": 0, "value": "-8", "easing": "Linear" }, { "position": 45, "value": "100", "easing": "Linear" } ],
291
- "txa:a2:offset": [ { "position": 7.5, "value": "-80", "easing": "Sine Smooth" }, { "position": 75, "value": "80", "easing": "Sine Smooth" } ] }
292
-
293
- // Endless neon pass: a glowing band loops across dim text with no keyframes.
294
- "textColor": "#56646f",
295
- "textAnimators": [{ "aid": "a1", "name": "Neon", "range": { "end": 26, "shape": "triangle", "flow": 36, "loop": true },
296
- "effects": [ { "pid": "p1", "type": "fill", "color": "#f2ffff" }, { "pid": "p2", "type": "glow", "color": "#36f1ff", "size": 36, "strength": 180 } ] }]
297
-
298
- // Fade Up (Time): words rise in 0.06 s apart, and leave upward at the end.
401
+ // Fade Up, pace-adjusted (Time; slower than the app's Classic Fade Up): words rise in 0.06 s apart, and leave upward at the end.
299
402
  "textAnimators": [
300
403
  { "aid": "a1", "name": "In", "range": { "drive": "time", "animate": "in", "basedOn": "words", "stagger": 0.06, "duration": 0.8, "curve": "Smooth" },
301
404
  "effects": [ { "pid": "p1", "type": "position", "x": 0, "y": 50 }, { "pid": "p2", "type": "opacity", "opacity": 0 } ] },
302
405
  { "aid": "a2", "name": "Out", "range": { "drive": "time", "animate": "out", "basedOn": "words", "stagger": 0.06, "duration": 0.7, "curve": "Smooth" },
303
406
  "effects": [ { "pid": "p3", "type": "position", "x": 0, "y": -50 }, { "pid": "p4", "type": "opacity", "opacity": 0 } ] }]
304
407
 
305
- // Energetic in and out (Swing Pop and 20 more, each two Time animators): video-text-kinetic.md.
306
- // Every other app preset (typing reveals, text on shapes, highlights, loops, decoders, voice): video-text-presets.md.
307
-
308
- // Caption karaoke on the voice (needs textWordTimings): a box sits under each word while it is spoken.
309
- "textAnimators": [{ "aid": "a1", "name": "Spoken box", "range": { "drive": "voice", "animate": "active", "basedOn": "words", "duration": 0.08, "curve": "Ease Out", "fadeOut": 0.08 },
310
- "effects": [ { "pid": "p1", "type": "box", "color": "#33efab", "padX": 12, "padY": 4, "radius": 14, "grow": false }, { "pid": "p2", "type": "fill", "color": "#0b0d0c" } ] }]
311
-
312
- // Letters typed at speaking pace behind a caret (captions).
313
- "textAnimators": [{ "aid": "a1", "name": "Type", "range": { "drive": "voice", "animate": "in", "basedOn": "chars", "duration": 0.04, "curve": "Linear", "spread": true },
314
- "effects": [ { "pid": "p1", "type": "opacity", "opacity": 0 }, { "pid": "p2", "type": "caret", "style": "bar" } ] }]
315
-
316
408
  // Typewriter with a caret (Time): letters appear 0.05 s apart; the caret blinks once typing ends and hides 1 s later.
317
409
  "textAnimators": [{ "aid": "a1", "name": "Type", "range": { "drive": "time", "animate": "in", "basedOn": "charsNoSpace", "stagger": 0.05, "duration": 0, "curve": "Linear" },
318
410
  "effects": [ { "pid": "p1", "type": "opacity", "opacity": 0 }, { "pid": "p2", "type": "caret", "style": "bar", "hide": true, "hideAfter": 1 } ] }]
@@ -374,8 +466,8 @@ PUT /api/moodboards/{board_id}/video-projects/{project_id}
374
466
  }
375
467
  ```
376
468
 
377
- `sanitizeTimelineItem` fills every omitted field (transitions, filters, opacity, etc.) with defaults, so the caption item above is complete. For multiple captions, reuse the channel only when their visible windows do **not** overlap. Any lines that must appear together (for example eyebrow + headline + sub-caption) need separate channels, one live item per channel.
469
+ The app fills every omitted field (transitions, filters, opacity, etc.) with defaults, so the caption item above is complete. For multiple captions, reuse the channel only when their visible windows do **not** overlap. Any lines that must appear together (for example eyebrow + headline + sub-caption) need separate channels, one live item per channel.
378
470
 
379
- 3. To produce the finished file with the caption burned in, `POST /api/moodboards/{board_id}/video-projects/{project_id}/render-onserver` — the full headless renderer bakes the animated caption in (the fast `/render` won't). Optionally `.../activate` first so it also opens as the board's active project.
471
+ 3. To produce the finished file with the caption burned in, `POST /api/moodboards/{board_id}/video-projects/{project_id}/render-onserver` (the full render; see the note at the top). Optionally `.../activate` first so it also opens as the board's active project.
380
472
 
381
473
  * * *