@shotstack/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotstack/cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Command-line interface for the Shotstack video rendering API.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/shotstack/shotstack-cli",
@@ -15,16 +15,16 @@ This skill loads in **terminal-based AI agents** (Claude Code, Cursor, Codex CLI
15
15
 
16
16
  ## Check versions once per session
17
17
 
18
- This skill is version **0.10.0** and needs `@shotstack/cli` **0.10.0** or later. Before the first `shotstack` command in a session:
18
+ This skill is version **0.12.0** and needs `@shotstack/cli` **0.12.0** or later. Before the first `shotstack` command in a session:
19
19
 
20
- 1. Run `shotstack --version`. If it is older than 0.10.0, tell the user: what this skill documents, such as `generate --quote`, is missing until they run `npm install -g @shotstack/cli@latest`.
20
+ 1. Run `shotstack --version`. If it is older than 0.12.0, tell the user: what this skill documents, such as `studio --env`, is missing until they run `npm install -g @shotstack/cli@latest`.
21
21
  2. Run `npm view @shotstack/cli version`. If it is newer than this skill's version, tell the user an update is available and ask whether to install it. Only on a yes, run `npx skills update shotstack -y` and `npm install -g @shotstack/cli@latest`. Never update without asking. `npx skills check` is not a read-only check: it installs the update.
22
22
 
23
23
  If a check fails (no network, no npm, a sandbox), skip it and carry on.
24
24
 
25
25
  **Open-ended first render? Ask before composing.** If the ask doesn't state what the video is (subject, format or destination) — e.g. "render my first video", "make me a video", "set up Shotstack" — read [`references/onboarding.md`](references/onboarding.md) and ask its discovery questions, then WAIT for answers. Do not write an Edit first. An open-ended ask is not a specification, and a stray JSON file in the project doesn't make the user experienced. Compose without asking only when the user has stated what the video is, or explicitly says to just show anything.
26
26
 
27
- Commands for the Shotstack video rendering API. `render` submits an Edit JSON and returns a render ID; `status` polls a render until done. `ingest` uploads your own local files (or fetches remote URLs) and hosts them so they can be referenced from an Edit. `validate` lints an Edit — run it before every render. `studio` opens an edit in the browser editor for a human to preview, tweak, and render. `template list|get|create|update|delete` reads and writes templates saved to the account: `list` shows saved templates (id + name); `get <id>` fetches a saved template's Edit JSON (e.g. one the user designed in Studio) so you can render or remix it; `create <file> --name <name>` saves an edit as a new template; `update <id> <file>` overwrites one; `delete <id>` removes one. `models` lists the generation models for prompt-driven assets and whether this key can use each one; `models <id>` prints the options a model accepts. `generate <model> "<prompt>"` generates one asset on its own and prints its URL.
27
+ Commands for the Shotstack video rendering API. `render` submits an Edit JSON and returns a render ID; `status` polls a render until done. `ingest` uploads your own local files (or fetches remote URLs) and hosts them so they can be referenced from an Edit. `validate` lints an Edit — run it before every render. `studio` opens an edit in the browser editor for a human to preview, tweak, and render. `template list|get|create|update|delete` reads and writes templates saved to the account: `list` shows saved templates (id + name); `get <id>` fetches a saved template's Edit JSON (e.g. one the user designed in Studio) so you can render or remix it; `create <file> --name <name>` saves an edit as a new template; `update <id> <file>` overwrites one; `delete <id>` removes one. `models` lists the generation models for prompt-driven assets and whether this key can use each one; `models <id>` prints the options a model accepts. `generate <model> "<prompt>"` generates one asset on its own and prints its URL. `analyze <url>` measures a music track's tempo, beats, bars, drops and sections, free and without a key.
28
28
 
29
29
  **Default loop — no API key required:** compose → `validate` (offline lint) → `studio` (browser preview). Only the API-backed commands (`render`, `status`, `ingest`, `models`, `generate`) need a key. Reach for `render` when you specifically need a final MP4 exported in the cloud; reach for `studio` for everything else — it is the cheapest way to see an edit and the right default for any creative a human will review.
30
30
 
@@ -117,22 +117,25 @@ shotstack generate seedance-2.0-text-to-video "Waves break over black sand at du
117
117
 
118
118
  **Generation costs credits on stage as well as v1.** Before generating, run the same command with `--quote`, tell the user the model and the credits it prints (it says "up to" when the final charge can be lower), and wait for a yes. Identical requests share one job and its cached result: repeating a request while it runs, or after it finishes, returns the same job without charging again. That is also how to pick up a job started without `--watch`. Sending a failed generation again retries it, and that is charged.
119
119
 
120
+ **Cutting or animating to music?** Measure the track with `shotstack analyze <url> --output json` and follow [`references/beat-sync.md`](references/beat-sync.md): write every cut and keyframe in bars from the map, never from the BPM you asked for.
121
+
120
122
  **Read [`references/generation.md`](references/generation.md) before writing a prompt.** It covers what each model responds to, when to put text in a `rich-text` layer instead of the image, and how to size and chain generations inside an edit.
121
123
 
122
124
  ## Default workflow: preview in Studio (no key, no credits)
123
125
 
124
- **`shotstack studio <file>` is the default way to look at an edit** — prefer it over `render` unless you specifically need a cloud-exported MP4. It posts the JSON to https://shotstack.studio and opens `https://shotstack.studio/s/<slug>` in the browser — a short, shareable URL. No API key, no render credits charged; it previews client-side, so it works with no key. The human plays, edits, and decides whether to spend credits on a render.
126
+ **`shotstack studio <file>` is the default way to look at an edit** — prefer it over `render` unless you specifically need a cloud-exported MP4. It posts the JSON to https://shotstack.studio and opens `https://shotstack.studio/s/<slug>` in the browser — a short, shareable URL. No render credits charged; it previews client-side, so it works with no key (a key only makes the short link permanent). The human plays, edits, and decides whether to spend credits on a render.
125
127
 
126
128
  ```sh
127
129
  shotstack studio template.json # opens browser + prints short URL
128
130
  shotstack studio template.json --no-open # headless: just print the URL
129
131
  shotstack studio template.json --no-shorten # emit base64url URL inline (offline / debug)
132
+ shotstack studio template.json --env stage # share with the saved stage key
130
133
  shotstack studio template.json --output json # piping: {"url":"...","shortened":true}, no browser
131
134
  ```
132
135
 
133
136
  On headless systems (no `xdg-open`, no `$DISPLAY`) the browser launch silently no-ops; the URL is still printed. Safe to run anywhere.
134
137
 
135
- If the share API is unreachable, the command falls back to the inline base64url form automatically and prints a stderr warning. Shares expire after 30 days.
138
+ **The short link is permanent when the CLI has an API key** (`SHOTSTACK_API_KEY`, or the key saved for `--env`/`SHOTSTACK_ENV`). Without one it lasts 30 days and the command says so on stderr; suggest `shotstack login` if the user wants links that last. A timeout, 429 or 5xx gets one retry, then the command falls back to the inline base64url form and prints the cause on stderr. If the share API refuses the edit (over 1 MB, missing `timeline` or `output`, or an invalid key), the command exits 1 with the reason: fix that, or use `--no-shorten`.
136
139
 
137
140
  **Default to `studio` for anything non-trivial** — multi-scene, multi-track, or any creative an interested human will review. It previews client-side, costs nothing, and lets the human hit Render when it's right. Use `render` directly only for single-shot/simple edits, automated pipelines, or when told "just render it".
138
141
 
@@ -148,7 +151,7 @@ If the share API is unreachable, the command falls back to the inline base64url
148
151
 
149
152
  ## REQUIRED: Read `shared/agent-core.md` before composing any Edit JSON
150
153
 
151
- The Shotstack schema does **not** match CSS or web conventions. Composing Edit JSON from training-data instinct will produce silently invalid renders. **Read [`shared/agent-core.md`](shared/agent-core.md) first**, every time. The most-failed conversions:
154
+ Edit JSON does **not** follow CSS or web conventions, least of all the `rich-text` asset's styling fields (an `html5` asset's `css` is ordinary CSS). Composing Edit JSON from training-data instinct will produce silently invalid renders. **Read [`shared/agent-core.md`](shared/agent-core.md) first**, every time. The most-failed conversions:
152
155
 
153
156
  | You'd write (wrong) | API requires (right) |
154
157
  |---|---|
@@ -160,9 +163,9 @@ The Shotstack schema does **not** match CSS or web conventions. Composing Edit J
160
163
  | `fit: "cover"` (CSS instinct) | `fit: "crop"` |
161
164
  | `tracks[0]` is the bottom (z-index instinct) | `tracks[0]` is the **TOP** layer |
162
165
 
163
- The full ruleset (asset types, fonts, smart-string clip values, top-5 mistakes) lives in `shared/agent-core.md`. The same file is also returned by the Shotstack MCP server's `get_shotstack_guide` tool, so the conventions are identical across surfaces.
166
+ The full ruleset (asset types, fonts, smart-string clip values, top-5 mistakes) lives in `shared/agent-core.md`.
164
167
 
165
- **For anything that animates, compose motion from the house tokens** — one duration scale (`base` 0.6 s in / `fast` 0.33 s out), one house ease (`power3.out` / `cubic-bezier(0.16,1,0.3,1)`, no overshoot by default), one stagger (`0.13 s`). The *Motion language* section of `agent-core.md` is the summary; [`references/motion.md`](references/motion.md) has the full GSAP/CSS recipes and the brand kit. **Don't invent a new easing or duration per clip** — shared tokens are what make a multi-clip edit feel like one production.
168
+ **Motion is yours to design.** Built-in motion (transitions, effects, keyframes, rich-text animation) takes fixed options from the schema; `html5` takes any seekable animation. [`references/motion.md`](references/motion.md) covers what each layer can animate (transitions, effects, keyframes, rich-text animation, `html5`), the easing available and recipes; the *Motion* section of `agent-core.md` summarises it.
166
169
 
167
170
  ## Exit codes
168
171
 
@@ -193,11 +196,13 @@ This skill ships sub-references for the gnarly bits:
193
196
  - [`references/positioning.md`](references/positioning.md) — coordinate model, `position`/`offset` (fraction of frame, +y up), clip bounding box & `fit`, text sizing, transform order
194
197
  - [`references/caption.md`](references/caption.md) — sizing per resolution, default style, the 5 named presets, alias pattern (asset type: `rich-caption`)
195
198
  - [`references/svg.md`](references/svg.md) — required attrs, supported elements
196
- - [`references/motion.md`](references/motion.md) — **the house motion language**: one duration scale, one ease, one stagger; choreography recipes (GSAP/CSS), the rich-text/transition mappings, and the brand kit. Read before composing any animation.
197
- - [`references/html5.md`](references/html5.md) — HTML5 asset: fields, preloaded libs (gsap/d3/anime/lottie), browser harness, sizing, worked examples
199
+ - [`references/motion.md`](references/motion.md) — what moves on each layer (transitions, effects, keyframes, rich-text animation, `html5`), the easing available, GSAP/CSS recipes, and the brand kit through merge fields
200
+ - [`references/html5.md`](references/html5.md) — HTML5 asset: fields, preloaded libs (GSAP and its plugins, d3, anime, lottie), browser harness and seek rules, 3D, sizing, worked examples
201
+ - [`references/html5-effects.md`](references/html5-effects.md) — **the creative palette**: every effect an `html5` clip can do, by common motion-design name (per-character text, stroke write-ons, mattes, blend modes, noise displacement, speed ramps, 2.5D camera, particles), with one-line recipes and what to avoid. Read before designing an `html5` clip.
198
202
  - [`references/html5-snippets.md`](references/html5-snippets.md) — copy-paste motion-graphic clips: kinetic headline, value reveal, shine sweep, pulsing CTA, film grain
199
203
  - [`references/fonts.md`](references/fonts.md) — built-in fonts, Google Fonts URL pattern, custom-font workflow
200
204
  - [`references/generation.md`](references/generation.md) — per-model prompting craft for AI-generated video, images, speech and music, and what a timeline adds to a prompt. Read before writing any `prompt`.
205
+ - [`references/beat-sync.md`](references/beat-sync.md) — cutting and animating to music: get the track first, analyse it, write every cue in bars, drive `html5` from the map, check frames at bar lines
201
206
  - [`references/asset-library.md`](references/asset-library.md) — placeholder videos, images, music
202
207
  - [`references/troubleshooting.md`](references/troubleshooting.md) — common errors and fixes
203
208
 
@@ -32,6 +32,8 @@ https://shotstack-assets.s3.amazonaws.com/images/waterfall.jpeg
32
32
 
33
33
  Royalty-free tracks from the Unminus library, hosted on Shotstack's CDN. Use as `audio` assets on a dedicated track. (`timeline.soundtrack` is deprecated — use `audio` with `length: "end"` instead.)
34
34
 
35
+ These are the fallback, not the default: the same few tracks sit under every video that uses them. When the user has no track, offer to generate one first (see *Music* in [`shared/agent-core.md`](../shared/agent-core.md)).
36
+
35
37
  ```
36
38
  https://shotstack-assets.s3.amazonaws.com/music/unminus/white.mp3
37
39
  https://shotstack-assets.s3.amazonaws.com/music/unminus/waveform.mp3
@@ -0,0 +1,78 @@
1
+ # Beat sync
2
+
3
+ Cut and animate to a track by measuring it, not by trusting the tempo you asked for. A generated track keeps a stated BPM but puts its drops and sections where it likes, and a tempo found by ear or by a general-purpose library is often half a beat off the kick. A cut that lands a quarter-beat late reads as a mistake.
4
+
5
+ ## 1. Get the track before you compose
6
+
7
+ Analysis needs a file. A `prompt` on the timeline's `audio` asset only generates at render time, so there is nothing to measure while you write the edit.
8
+
9
+ - **The user's track:** use its hosted URL.
10
+ - **A library track:** use its URL as is.
11
+ - **A generated track:** generate it on its own first, then use the URL it returns as a plain `audio` `src`. Still state the BPM in the prompt; measure the result anyway.
12
+
13
+ <!-- surface:cli -->
14
+ Host a local file with `shotstack ingest upload <file> --watch`. Generate a track with `shotstack generate <music-model> "<prompt>" --length <seconds> --watch`, after quoting it with `--quote`.
15
+ <!-- /surface -->
16
+
17
+ ## 2. Analyse it
18
+
19
+ Analysis is free, takes 15–30 s and reads the first 10 minutes of the track.
20
+
21
+ It needs no API key. The `src` must be a direct, public link to an audio or video file: share pages and redirects are refused.
22
+ <!-- surface:cli -->
23
+ ```sh
24
+ shotstack analyze https://shotstack-assets.s3.amazonaws.com/music/unminus/lit.mp3 --output json > map.json
25
+ ```
26
+ <!-- /surface -->
27
+
28
+ ## 3. Read the map
29
+
30
+ | Field | Meaning |
31
+ |---|---|
32
+ | `steady` | `true`: the tempo holds, and `bpm`, `beat`, `bar`, `meter` and `origin` describe a grid. `false`: use `beats[]` and `downbeats[]` instead. |
33
+ | `bpm`, `beat`, `bar` | Tempo, and the beat and bar lengths in seconds. |
34
+ | `meter` | Beats per bar. |
35
+ | `origin` | Time of the first downbeat. Bar `n` starts at `origin + n × bar`. |
36
+ | `bars.count`, `bars.energy[]` | Number of bars, and loudness per bar from 0 to 1. |
37
+ | `drops[]` | `{ bar, time }` where the low end comes in hard. |
38
+ | `sections[]` | `{ bar, time, change }`, where `change` is `rise` or `fall`. |
39
+ | `downbeat.confidence` | How sure the bar phase is, 0 to 1. |
40
+ | `precision` | How far to trust each part: `grid` is `frame` or `beat`, `sections` is `bar`, `downbeat` is `estimate`. |
41
+ | `beats[]`, `downbeats[]` | Beat and bar times, only when `steady` is `false`. |
42
+
43
+ ## 4. Write every cue in bars
44
+
45
+ - **Cuts on bar lines.** A shot holds a whole number of bars. Compute each `start` and `length` from the map and keep three decimals; never round to whole seconds.
46
+ - **Let energy set the pace.** Hold two or four bars where `bars.energy` is low and cut every bar where it is high.
47
+ - **Spend the big moment on a drop.** Put the reveal, the logo or the hardest cut at a `drops[].time`.
48
+ - **Change scene at sections.** A `rise` opens a new idea; a `fall` is where to breathe.
49
+ - **Accents on beats.** A pulse, flash or kick-on-the-beat sits at `origin + k × beat`.
50
+ - **No grid?** When `steady` is `false`, cut on `downbeats[]` and accent on `beats[]`.
51
+ - **Downbeat off by one?** The bar phase is an estimate. If `downbeat.confidence` is low, look at the first drop: if it lands mid-bar, shift `origin` by a beat.
52
+
53
+ ## 5. Drive `html5` motion from the map
54
+
55
+ An `html5` clip's time is local to the clip. Put the map's numbers and the clip's own `start` in the page and place every tween from them, on a timeline the harness seeks (not `paused`):
56
+
57
+ ```js
58
+ const ORIGIN = 0.062, BAR = 1.9998, BEAT = BAR / 4, CLIP_START = 8.062; // from the map and this clip
59
+ const at = (bar, beat = 0) => ORIGIN + bar * BAR + beat * BEAT - CLIP_START;
60
+ const tl = gsap.timeline();
61
+ tl.from(".title", { yPercent: 110, duration: BEAT, ease: "power3.out" }, at(4))
62
+ .to(".title", { scale: 1.06, duration: BEAT / 2, yoyo: true, repeat: 1 }, at(5))
63
+ .to(".title", { opacity: 0, duration: BEAT }, at(7, 3));
64
+ ```
65
+
66
+ Built-in motion works the same way: a clip whose `start` is a bar line makes its `transition` land on the beat.
67
+
68
+ ## 6. Keep the music a plain audio clip
69
+
70
+ Put the track on its own track as an `audio` asset at `start: 0` with `length: "end"`; a fade (`effect: "fadeOut"`) is fine. Anything that moves the audio moves the grid with it. If you trim the track or start it later, add that offset to every time: timeline time = map time − `trim` + the audio clip's `start`.
71
+
72
+ ## 7. Check frames at bar lines
73
+
74
+ Before calling it done, look at a few cuts and the first drop at their bar times: the new shot should be on screen at the bar line, not a frame or two after it.
75
+
76
+ <!-- surface:cli -->
77
+ Preview with `shotstack studio`, or render on stage and pull a frame at a bar time with `ffmpeg -ss <time> -i out.mp4 -frames:v 1 bar.png`.
78
+ <!-- /surface -->
@@ -64,7 +64,9 @@ Speech length is not knowable in advance. Set the clip's `length` to `"auto"` an
64
64
 
65
65
  **Eleven Music.** A prompt answers genre, mood, instrumentation, tempo and production era whether or not you intend it to. Whatever you leave out, the model chooses.
66
66
 
67
- **Production vocabulary does real work** — "dusty", "vinyl crackle", "sparse", "ethereal". Give a BPM when timing matters; the model holds a stated tempo precisely. Naming a key helps, less reliably.
67
+ **Production vocabulary does real work** — "dusty", "vinyl crackle", "sparse", "ethereal". Naming a key helps, less reliably.
68
+
69
+ **State a BPM, then measure.** The model holds a stated tempo closely, but it places drops and sections where it likes, whatever the prompt says. Before timing cuts or motion to a generated track, analyse it: see [`beat-sync.md`](beat-sync.md).
68
70
 
69
71
  **Naming a musician or band is refused**, as is quoting copyrighted lyrics. The error includes a suggested alternative prompt. Describe the era and production style instead: "1990s trip-hop with detuned Rhodes and a heavy swung break".
70
72
 
@@ -0,0 +1,109 @@
1
+ # HTML5 effects palette
2
+
3
+ What you can build inside one `html5` clip, listed by the effect you'd reach for in a motion-graphics or editing tool. Every recipe renders the same in Studio's preview and the cloud render, on the CPU renderer, unless its note says otherwise. Timing and easing come from [`motion.md`](motion.md); the rules every recipe obeys are in [`html5.md`](html5.md). Snippets assume a GSAP timeline `tl = gsap.timeline()`.
4
+
5
+ **Match** means preview and render agree. **Caveat** names what differs. **Not available** gives the alternative.
6
+
7
+ ## Text
8
+
9
+ | Effect | Recipe | Status |
10
+ |---|---|---|
11
+ | Per-character animation | `const s = SplitText.create("#t", { type: "chars" }); tl.from(s.chars, { y: 40, opacity: 0, rotation: -15, stagger: 0.04 })` | Match |
12
+ | Per word / per line | `type: "words"` or `type: "lines"`. For lines, embed the font and build the tween in `onSplit` with `autoSplit: true`, so lines are measured with the real font | Match |
13
+ | Tracking in / out | `tl.fromTo("#t", { letterSpacing: "0.6em" }, { letterSpacing: "0.12em", duration: 2 })` | Match |
14
+ | Typewriter | `tl.to("#t", { text: "Final copy", duration: 1.5, ease: "none" })` | Match |
15
+ | Text decoder | `tl.to("#t", { scrambleText: { text: "DECODED", chars: "01" }, duration: 1.2 })` | Caveat: random glyphs; only the final text matches |
16
+ | Text on a path | SVG `<text><textPath href="#curve" id="tp">…</textPath></text>`, then `tl.to("#tp", { attr: { startOffset: "30%" } })` | Match |
17
+ | Stroke, gradient fill, glow | `-webkit-text-stroke: 2px #fff` · `background: linear-gradient(…); -webkit-background-clip: text; color: transparent` · `text-shadow: 0 0 12px #0ff` | Match, glow edges soften slightly in preview |
18
+
19
+ ## Shapes and paths
20
+
21
+ | Effect | Recipe | Status |
22
+ |---|---|---|
23
+ | Stroke write-on | `tl.fromTo("#path", { drawSVG: "0%" }, { drawSVG: "100%" })` | Match |
24
+ | Travelling stroke segment | `tl.fromTo("#path", { drawSVG: "0% 10%" }, { drawSVG: "90% 100%" })` | Match |
25
+ | Shape morph | `tl.to("#shapeA", { morphSVG: "#shapeB" })` | Match |
26
+ | Motion path, auto-orient | `tl.to("#dot", { motionPath: { path: "#track", align: "#track", autoRotate: true, alignOrigin: [0.5, 0.5] } })`, or CSS `offset-path: path("M…")` with `tl.to(el, { offsetDistance: "100%" })` | Match |
27
+ | Repeated copies of a shape | Generate N copies in JS, then `tl.from(copies, { rotation: (i) => i * 30, opacity: 0, stagger: 0.05 })` | Match |
28
+ | Boolean path operations, path offset | Not available. Draw the merged outline as one SVG path | — |
29
+
30
+ ## Masks and mattes
31
+
32
+ | Effect | Recipe | Status |
33
+ |---|---|---|
34
+ | Animated mask (wipe, reveal) | `tl.fromTo(el, { clipPath: "inset(0% 100% 0% 0%)" }, { clipPath: "inset(0% 0% 0% 0%)" })` or a `polygon(…)` with the same point count | Match |
35
+ | Mask feather | `mask-image: linear-gradient(90deg, #000 60%, transparent)` | Match |
36
+ | Alpha matte (fill through text) | SVG `<mask id="m"><text fill="#fff">MATTE</text></mask>` on a gradient or image; animate what's behind | Match |
37
+ | Luma matte | `<mask style="mask-type: luminance">` containing a gradient | Match |
38
+ | Inverted matte | In the SVG mask, a white rect with the shape drawn in black | Match |
39
+ | HTML content as a matte | Not available. The matte must be SVG content or an image | — |
40
+
41
+ ## Blend modes and adjustment layers
42
+
43
+ | Effect | Recipe | Status |
44
+ |---|---|---|
45
+ | Multiply, screen, overlay, difference, colour dodge, add | `mix-blend-mode: multiply` (also `plus-lighter` for add) | Match |
46
+ | Linear burn, vivid or pin light, hard mix, subtract, divide, dissolve | Not available. Nearest: `color-burn`, `hard-light`, `difference` | — |
47
+ | Adjustment layer | An element over the layers it affects with `backdrop-filter: blur(10px) saturate(1.4)` | Match. Caveat: costly over large areas |
48
+ | Blending isolated to a group | `isolation: isolate` on the group | Match |
49
+
50
+ ## Effects
51
+
52
+ | Effect | Recipe | Status |
53
+ |---|---|---|
54
+ | Gaussian blur | `filter: blur(8px)`; animate with `tl.to(el, { filter: "blur(0px)" })` | Match |
55
+ | Directional blur | SVG `<filter><feGaussianBlur stdDeviation="14 0"/></filter>` (x only) | Match |
56
+ | Glow | `filter: drop-shadow(0 0 12px #22d3ee) drop-shadow(0 0 28px #a855f7)` | Caveat: halo slightly softer in preview |
57
+ | Drop shadow | `filter: drop-shadow(0 12px 24px rgba(0,0,0,.5))` | Match |
58
+ | Noise displacement, wave warp | `<feTurbulence id="t" type="fractalNoise" baseFrequency="0.02" numOctaves="3"/>` → `<feDisplacementMap id="d" in="SourceGraphic" scale="30"/>`; animate `tl.to("#t", { attr: { baseFrequency: 0.05 } })` and `#d` `scale` | Match |
59
+ | Fractal noise | `<feTurbulence type="turbulence">` then `<feColorMatrix>` to tint | Match. Caveat: about 75 ms per full 1080p frame |
60
+ | Curves, levels, hue/saturation, tint | `<feComponentTransfer>` (`table`, `gamma`), `<feColorMatrix type="hueRotate">` (animate `attr: { values }`), or CSS `saturate() contrast() hue-rotate()` | Match |
61
+ | Bevel, light sweep | `<feSpecularLighting>` with `<fePointLight id="p">`; animate `tl.to("#p", { attr: { x: 1920 } })` | Match |
62
+ | Lens glow, anamorphic streak | Radial or linear gradients with `mix-blend-mode: screen` | Match |
63
+ | Vignette | Overlay `radial-gradient(ellipse, transparent 55%, rgba(0,0,0,.6))` | Match |
64
+ | Film grain | Snippet 6 in [`html5-snippets.md`](html5-snippets.md) | Match |
65
+ | Motion blur | Not available. Fake it on fast moves with a short directional blur | — |
66
+
67
+ ## Motion and time
68
+
69
+ | Effect | Recipe | Status |
70
+ |---|---|---|
71
+ | Custom easing curve | `CustomEase.create("hop", "M0,0 C0.3,1 0.7,1 1,0")`, then `ease: "hop"` | Match |
72
+ | Bounce with squash | `CustomBounce.create("bnc", { strength: 0.6, squash: 2, squashID: "bnc-sq" })`; position uses `"bnc"`, scale uses `"bnc-sq"` | Match |
73
+ | Wiggle, random shake | `CustomWiggle.create("wig", { wiggles: 7, type: "easeOut" })`, then `ease: "wig"`. Never `type: "random"` | Match |
74
+ | Loop, ping-pong loop | `repeat: -1`, plus `yoyo: true` for ping-pong | Match |
75
+ | Nested scenes | Each scene is its own timeline on one master: `gsap.timeline().add(sceneA, 0).add(sceneB, 2.5)` | Match |
76
+ | Speed ramp, time remapping | `const scene = gsap.timeline({ paused: true }).to(…); gsap.globalTimeline.remove(scene); tl.to(scene, { progress: 1, duration: 3, ease: "power3.inOut" })`. The `remove` line is required | Match |
77
+ | Throw, fall, bounce | `tl.to(el, { physics2D: { velocity: 600, angle: -60, gravity: 900 }, duration: 2 })` | Match |
78
+ | Momentum glide to a stop | `tl.to(el, { inertia: { x: { velocity: 600, end: 300 } } })` | Match |
79
+ | Layout morph | `const st = Flip.getState(".item")`; change classes or order; `tl.add(Flip.from(st, { duration: 1 }))` | Match |
80
+ | Seeded randomness | `const rand = d3.randomLcg(42)`; never `Math.random()` | Match |
81
+
82
+ ## 3D and camera
83
+
84
+ | Effect | Recipe | Status |
85
+ |---|---|---|
86
+ | 3D layers and camera move (2.5D) | `perspective: 1600px` on a stage, `transform-style: preserve-3d` on a world, each layer at `translateZ(z) scale((p − z) / p)`; move the world with `tl.to("#world", { z: 150, y: 20, rotationY: -3 })` | Match. Order layers back to front |
87
+ | Depth of field, rack focus | Tween a `focus` object and set each layer's `blur(|z − focus| / 120 px)` in its `onUpdate` | Match. Caveat: six blurred full-frame layers cost about 340 ms per 1080p frame |
88
+ | Card flip, tilt | `perspective` and `tl.to(el, { rotationY: 180 })` | Match |
89
+ | Cube, carousel, crossing planes | Correct in the cloud render; Studio's preview paints faces in DOM order. Avoid | Caveat |
90
+ | 3D models, lights, materials, WebGL | Not available. Fake depth with layered 2.5D, gradients and SVG lighting | — |
91
+
92
+ ## Generative and data
93
+
94
+ | Use | Recipe | Status |
95
+ |---|---|---|
96
+ | Particles, generative art | Canvas 2D drawn from `window.__shotstackSeek = (ms) => { … }` with seeded positions; or DOM dots with `physics2D` | Match |
97
+ | Charts and data viz | D3 builds SVG, GSAP animates it (see the bar-chart example in [`html5.md`](html5.md)) | Match |
98
+ | Lottie animation | `lottie.loadAnimation({ container, renderer: "svg", autoplay: false, loop: false, animationData })`. Shapes, stroke write-ons, masks, mattes and nested compositions work | Caveat: expressions don't run; convert them to keyframes first |
99
+
100
+ ## Not available
101
+
102
+ - **WebGL, shaders, 3D models:** use CSS 2.5D and SVG filters.
103
+ - **Video or audio inside the clip:** put footage and sound on other timeline tracks; `html5` is the graphics layer.
104
+ - **Scroll, drag or pointer-driven animation:** capture is time-driven only.
105
+ - **Remote fonts, images, scripts:** inline them as `data:` URIs.
106
+
107
+ ## Cost
108
+
109
+ Most recipes cost 20–40 ms per 1080p frame on the CPU renderer. The expensive ones are full-frame blur or `backdrop-filter` (about 90 ms), fractal noise (about 75 ms) and stacks of blurred depth layers (about 340 ms). Keep heavy filters on small elements, and blur only the layers that need it.
@@ -4,36 +4,33 @@ Copy-paste `html5` clips that "pop" — kinetic type, value reveals, shine sweep
4
4
  pulsing CTAs, grain. Each is a **single clip**: paste it into a track's
5
5
  `clips[]`, set `start`/`length`, and position with `offset`.
6
6
 
7
- Every snippet here is composed from the house **[`motion.md`](motion.md)** tokens —
8
- one duration scale (`base` 0.6 s in, `fast` 0.33 s out), one house ease
9
- (`power3.out` / `cubic-bezier(0.16,1,0.3,1)`), one stagger (`0.13 s`), and a single
10
- shared palette (ink `#141414`, accent `#D96B82`). That shared vocabulary is what
11
- makes a set of these clips feel like one production. **When you adapt a snippet,
12
- keep the tokens** — change the words, the colours and the canvas size, not the
13
- easings and durations. Read [`motion.md`](motion.md) for the why and the full recipe set.
7
+ These are examples to remix. They share one palette (ink `#141414`, accent `#D96B82`)
8
+ so they combine cleanly as they are; change anything — timing, easing, travel, colour,
9
+ layout. [`motion.md`](motion.md) covers what each layer can animate and the easing
10
+ available.
14
11
 
15
12
  Read [`html5.md`](html5.md) for the rules these obey. The non-negotiables:
16
13
 
17
14
  - **Seekable animation only.** GSAP timelines, GSAP tweens, anime.js, Lottie, or CSS `@keyframes`. **Never** `setTimeout`/`setInterval`/`requestAnimationFrame`/`Date.now()`/`gsap.call()` — the renderer seeks by absolute time, it doesn't play.
18
- - **`onUpdate` does not fire under seek.** GSAP `onUpdate` callbacks are **not** invoked when the harness seeks to a frame, so any DOM mutations made inside them (`textContent`, `innerHTML`, class swaps) won't appear in the rendered video. Animate **CSS properties only** (opacity, transform, filter, scale). Bake final values into the HTML at generation time and reveal them with opacity/transform tweens.
15
+ - **Animate CSS properties; keep callbacks pure.** `onUpdate` runs on every seek, so it may only set values derived from the tween's progress. Never use `paused: true` timelines or `gsap.call()`: neither follows the renderer's seeks. Bake final values into the HTML at generation time and reveal them with opacity/transform tweens.
19
16
  - **Size the clip to the content, not the canvas.** `html, body` pinned to the clip's `width`/`height`; place with `offset` (`{x:0,y:0}` is centred, `y` positive is up). Use **px**, never `vw`/`vh`/`%`.
20
17
  - **The body is transparent by default** — the clip composites over the layers below (only set an opaque background if you want one).
21
- - **No `<canvas>`** — Studio capture serialises the DOM; canvas bitmaps come through empty. Use SVG or positioned DOM.
22
- - **No network.** gsap/anime/d3/lottie are preloaded; external `<script src>`, `fetch`, remote `<img>`, and remote fonts are all CSP-blocked — inline everything as `data:` URIs.
18
+ - **Canvas only from the seek hook.** Draw canvas 2D inside `window.__shotstackSeek(ms)`, never in a `requestAnimationFrame` loop. WebGL is blank in the cloud render. SVG or positioned DOM is usually lighter.
19
+ - **No network.** gsap (with its plugins), anime, d3 and lottie are preloaded; external `<script src>`, `fetch`, remote `<img>`, and remote fonts are all CSP-blocked — inline everything as `data:` URIs.
23
20
  - **Fonts:** `timeline.fonts[]` does **not** reach `html5` and remote `@font-face` is blocked. These snippets name a display font first but render in the `system-ui` fallback unless you inline the font as a `data:` `@font-face`.
24
21
  - **Use unique element IDs for GSAP targets, not `:nth-child` or compound CSS selectors.** Studio preview doesn't resolve `.parent:nth-child(n) .child` patterns; `#id` works everywhere. When animating repeated elements (list rows, bars, cards), generate a unique ID per item (`id="row0"`, `id="bar0"`) rather than relying on structural selectors.
25
22
 
26
23
  Coordinates below assume a **1080×1920 vertical** canvas; adjust `offset` for other sizes.
27
24
 
28
- > **Continuous-motion exception.** Looping or drifting effects (the pulse, the sweep, the grain) are *ambient*, not entrances — they correctly use `ease-in-out` / `linear` and their own loop durations rather than the entrance tokens. Everything that *reveals* uses the house entrance tokens.
25
+ > **Continuous motion.** Looping or drifting effects (the pulse, the sweep, the grain) are *ambient*, not entrances — they use `ease-in-out` / `linear` and their own loop durations.
29
26
 
30
27
  ---
31
28
 
32
29
  ## 1. Blur reveal — calm text entrance
33
30
 
34
- **Category** entrances · **Use when** the default text reveal; the calm house entrance for a title or line — reach for a punchier one (snippet 2) only with intent · **Canvas** 900×300 · **Tags** text, reveal, entrance, blur
31
+ **Category** entrances · **Use when** a calm text reveal for a title or line; for energy, use snippet 2 · **Canvas** 900×300 · **Tags** text, reveal, entrance, blur
35
32
 
36
- The reference entrance: opacity, blur and a 16 px rise settle **together** off one tween (one progress, many channels) on the house ease — no overshoot. Quietly cinematic.
33
+ The reference entrance: opacity, blur and a 16 px rise settle **together** off one tween (one progress, many channels) on `power3.out`. Quietly cinematic.
37
34
 
38
35
  ```json
39
36
  {
@@ -59,7 +56,7 @@ The reference entrance: opacity, blur and a 16 px rise settle **together** off o
59
56
 
60
57
  **Category** entrances · **Use when** a headline needs energy — a hype/hero title where each word punches up in sequence · **Canvas** 980×420 · **Tags** text, reveal, entrance, stagger, hero
61
58
 
62
- Each word springs up in sequence. GSAP timeline, fully seekable, on the house stagger.
59
+ Each word springs up in sequence. GSAP timeline, fully seekable, words `0.13 s` apart.
63
60
 
64
61
  ```json
65
62
  {
@@ -77,7 +74,7 @@ Each word springs up in sequence. GSAP timeline, fully seekable, on the house st
77
74
  }
78
75
  ```
79
76
 
80
- Each word starts `translateY(120%)`, opacity 0; `0.6 s` rise on `power3.out`, words `0.13 s` apart (the house stagger); a trailing empty tween holds the title still. **Punchy variant:** this is the one "hero" spot where a pop is allowed — swap `ease:'power3.out'` for `ease:'back.out(1.4)'` for a gentle overshoot. Keep it to one headline per scene. `Anton` falls back to `system-ui` unless inlined.
77
+ Each word starts `translateY(120%)`, opacity 0; `0.6 s` rise on `power3.out`, words `0.13 s` apart; a trailing empty tween holds the title still. **Punchy variant:** swap `ease:'power3.out'` for `ease:'back.out(1.4)'` for an overshoot, or tighten the stagger for faster delivery. `Anton` falls back to `system-ui` unless inlined.
81
78
 
82
79
  ---
83
80
 
@@ -137,7 +134,7 @@ A specular highlight slides across text — premium product gloss. *Continuous*
137
134
 
138
135
  **Category** graphics (emphasis) · **Use when** drawing the eye to a CTA on an end card · **Canvas** 620×170 · **Tags** cta, button, pulse, loop · **Accent** `#D96B82`
139
136
 
140
- A "SHOP NOW" pill with a soft breathing glow. *Ambient loop* — `ease-in-out`, its own 1.6 s cycle (exempt from the entrance tokens). CSS keyframes seek cleanly.
137
+ A "SHOP NOW" pill with a soft breathing glow. *Ambient loop* — `ease-in-out`, its own 1.6 s cycle. CSS keyframes seek cleanly.
141
138
 
142
139
  ```json
143
140
  {
@@ -155,7 +152,7 @@ A "SHOP NOW" pill with a soft breathing glow. *Ambient loop* — `ease-in-out`,
155
152
  }
156
153
  ```
157
154
 
158
- The accent (`#D96B82`) is used here as the single earned colour. The clip is wider/taller than the pill so the glow has room. For a non-looping single pop, replace `infinite` with `1`.
155
+ The accent (`#D96B82`) carries the glow. The clip is wider/taller than the pill so the glow has room. For a non-looping single pop, replace `infinite` with `1`.
159
156
 
160
157
  ---
161
158
 
@@ -197,12 +194,16 @@ The snippets share one palette (ink `#141414`, accent `#D96B82`) so a set alread
197
194
  ]
198
195
  ```
199
196
 
200
- Then in any snippet's CSS, swap the literal hex for the token: `color:{{ink}}`, `background:{{accent}}`, `font-family:{{font}}`. One edit re-skins every clip. Keep the accent **earned** — a headline word, a number, a CTA, one glow; everything else neutral. (If you ship a snippet *without* a matching `merge[]` entry, leave the literal hex in — an undefined `{{token}}` renders as invalid CSS.)
197
+ Then in any snippet's CSS, swap the literal hex for the token: `color:{{ink}}`, `background:{{accent}}`, `font-family:{{font}}`. One edit re-skins every clip. (If you ship a snippet *without* a matching `merge[]` entry, leave the literal hex in — an undefined `{{token}}` renders as invalid CSS.)
201
198
 
202
199
  ## Composing these
203
200
 
204
201
  - Each snippet is one clip on its own track. Layer order is top-track-first (see `agent-core.md`) — grain and shine go in **early** tracks, backgrounds in **late** ones.
202
+ <!-- surface:cli -->
205
203
  - They don't overlap on a single track, so `shotstack validate <file>` stays clean. Run it before rendering.
204
+ <!-- /surface -->
206
205
  - Reuse text via top-level `merge[]` (`{{title}}` in the HTML) — see the lower-third example in `html5.md`.
207
- - Mix calm and punchy deliberately: a `blur-reveal` title, a `kinetic-headline` hero line, a `value-reveal` stat, a `shine` on the product, a pulsing CTA — all on the same tokens, so the set reads as one piece.
206
+ - Mix calm and punchy: a `blur-reveal` title, a `kinetic-headline` hero line, a `value-reveal` stat, a `shine` on the product, a pulsing CTA.
207
+ <!-- surface:cli -->
208
208
  - Heavier motion = longer render. Preview in `shotstack studio <file>` before spending credits.
209
+ <!-- /surface -->
@@ -2,6 +2,8 @@
2
2
 
3
3
  The `html5` asset type renders a self-contained HTML/CSS/JS page inside a video clip. Use it for animated overlays, data visualisations, motion graphics, and anything you'd build as a tiny single-page web app.
4
4
 
5
+ For what you can make with it, effect by effect, see [`html5-effects.md`](html5-effects.md).
6
+
5
7
  This is the **modern replacement for the deprecated `html` asset.** `html5` runs in a real browser iframe with a JS runtime, library preloads, and deterministic frame capture — the old `html` asset should never be used.
6
8
 
7
9
  ## Contents
@@ -11,6 +13,7 @@ This is the **modern replacement for the deprecated `html` asset.** `html5` runs
11
13
  - The browser harness (deterministic auto-seek)
12
14
  - Sandbox restrictions: no network, inline everything (incl. fonts)
13
15
  - Sizing
16
+ - 3D: what renders in preview and render
14
17
  - Worked example: animated lower-third (GSAP)
15
18
  - Worked example: animated bar chart (D3 + GSAP)
16
19
  - Worked example: 10-second countdown (pure CSS)
@@ -45,20 +48,44 @@ Clip-level `width` and `height` set the iframe's pixel dimensions. They default
45
48
 
46
49
  ## Preloaded libraries
47
50
 
48
- Four libraries are always available — no `<script src=>` tags needed:
51
+ Four libraries and GSAP's plugins are always available — no `<script src=>` tags and no `gsap.registerPlugin()` call needed:
49
52
 
50
53
  - **GSAP** (`window.gsap`) — primary animation library. Use timelines (`gsap.timeline()`) over loose tweens; the harness seeks timelines correctly.
51
54
  - **anime.js** (`window.anime`) — alternative animation library.
52
- - **D3** (`window.d3`) — for data binding, scales, and SVG/DOM construction. Pair with GSAP for the actual animation; D3's transitions work too but GSAP is more reliable under seek.
53
- - **Lottie** (`window.lottie`) — Bodymovin JSON player (SVG-renderer build; `renderer: "canvas"` is unavailable).
55
+ - **D3** (`window.d3`) — for data binding, scales, and SVG/DOM construction. Pair with GSAP for the actual animation; D3's transitions work too but GSAP is more reliable under seek. `d3.randomLcg(seed)` is the seeded random source.
56
+ - **Lottie** (`window.lottie`) — Bodymovin JSON player (SVG-renderer build; `renderer: "canvas"` is unavailable). Expressions in the file (`wiggle`, `loopOut`) don't run: a layer that carries one doesn't render. Bake expressions into keyframes before export (After Effects: *Animation → Keyframe Assistant → Convert Expression to Keyframes*).
57
+
58
+ GSAP 3.15 plugins, already registered:
59
+
60
+ | Plugin | Use it for |
61
+ |---|---|
62
+ | `SplitText` | Per-character, word or line animation. `SplitText.create("#title", { type: "chars" })` |
63
+ | `TextPlugin` | Typewriter text: `gsap.to(el, { text: "Hello", duration: 1 })` |
64
+ | `ScrambleTextPlugin` | Decoding text. Picks random glyphs, so only its final text matches between preview and render |
65
+ | `DrawSVGPlugin` | Stroke write-ons and travelling stroke segments: `{ drawSVG: "0%" } → { drawSVG: "100%" }` |
66
+ | `MorphSVGPlugin` | Morph one SVG path into another |
67
+ | `MotionPathPlugin` | Move along an SVG path, with `autoRotate` |
68
+ | `Flip` | Animate between two layouts: `Flip.getState`, change the layout, `Flip.from(state)` |
69
+ | `CustomEase`, `CustomBounce`, `CustomWiggle`, `EasePack` | Graph-editor curves, bounce, wiggle and slow-mo eases. Avoid `CustomWiggle` `type: "random"` and `RoughEase`: they're random |
70
+ | `Physics2DPlugin`, `PhysicsPropsPlugin`, `InertiaPlugin` | Velocity, gravity and friction motion |
54
71
 
55
- These cover ~95% of motion-graphics use cases. **You can't load other libraries via `<script src=>`** — the iframe's CSP blocks all external scripts and network access (see *Sandbox restrictions* below).
72
+ ScrollTrigger, ScrollSmoother, Observer and Draggable aren't bundled: capture never scrolls or takes input.
73
+
74
+ **You can't load other libraries via `<script src=>`** — the iframe's CSP blocks all external scripts and network access (see *Sandbox restrictions* below).
56
75
 
57
76
  ## The browser harness (deterministic auto-seek)
58
77
 
59
78
  Frames are captured by **seeking** the animation to each timestamp, not by playing in real time — so your animation must be **seekable**. GSAP (timelines or tweens), anime.js, Lottie, and CSS (`@keyframes`, transitions, `Element.animate()`) are all driven automatically. Anything time-driven that isn't seekable gives a frozen or wrong frame: never use `setTimeout`, `setInterval`, `requestAnimationFrame` loops, `Date.now()` / `performance.now()`, or `gsap.call()`. For "different content at different times" (countdowns, tickers, scene swaps) use the staggered-CSS pattern (see the countdown example) or bake values into the HTML and animate their visibility (see the value-reveal snippet).
60
79
 
61
- **`onUpdate` callbacks do not fire under seek.** The harness seeks the GSAP timeline to each frame's timestamp without playing through, so `onUpdate` handlers are not invoked. Any DOM mutation made inside an `onUpdate` callback (`textContent`, `innerHTML`, class swaps, attribute changes) will not appear in the rendered video — the element stays at its initial state. **Animate CSS properties only** (opacity, transform, filter, scale) — those are applied directly by GSAP's seek. To display a value, bake it into the HTML at generation time and reveal it with an opacity/transform tween.
80
+ **Callbacks.** `onUpdate` runs on every seek for tweens and timelines the harness drives, so it can set what GSAP can't tween directly (a blur computed from depth, a counter's text), provided it depends only on the tween's own progress. It does **not** run for timelines created with `paused: true`, and `gsap.call()` fires when the playhead passes it but isn't undone when the renderer seeks back. Avoid both. Prefer animating CSS properties (opacity, transform, filter) and baking values into the HTML.
81
+
82
+ **Scenes as nested timelines.** Build each scene as its own timeline and place it on one master: `gsap.timeline().add(sceneA, 0).add(sceneB, 2.5)`. Nested timelines and `delay` play at their offsets, the way nested compositions sit on an editing timeline.
83
+
84
+ **Randomness must be seeded.** Preview and render must draw the same values, so never use `Math.random()`. Use `const rand = d3.randomLcg(42)` and generate positions, colours and velocities from it at load.
85
+
86
+ **Initial state in CSS or `from()`.** Put each element's starting state in its CSS or in a `from()` / `fromTo()`. A `gsap.set()` made before the timeline can miss the first frame of the cloud render.
87
+
88
+ **Custom drawing.** For anything the libraries don't drive (canvas 2D, SVG built in code), define `window.__shotstackSeek = function (ms) { … }` and redraw the whole frame for `ms` from scratch. The harness calls it on every frame, after GSAP.
62
89
 
63
90
  **Duration comes from the clip's `length`** — there's no animation-duration auto-detection. Size your animation to run within (or fill) the clip's `length`.
64
91
 
@@ -80,7 +107,7 @@ The iframe renders under a strict Content-Security-Policy (`default-src 'none'`)
80
107
  @font-face { font-family: 'Brand'; src: url('data:font/woff2;base64,<…>') format('woff2'); }
81
108
  .title { font-family: 'Brand', sans-serif; }
82
109
  ```
83
- 2. **Use a system family** (`system-ui`, `Arial`, `Georgia`, …) — resolves in the render browser with no load.
110
+ 2. **Use a system family** (`system-ui`, `Arial`, `Georgia`, …) — resolves with no load, but each machine picks its own font, so text in Studio's preview won't match the cloud render exactly. Embed a font whenever the text must match.
84
111
 
85
112
  An unresolved family silently falls back to the browser default — the render won't fail, but the text won't be your font. For a single styled line, a `rich-text` asset (which *does* use `timeline.fonts[]`; verified catalogue in `shared/agent-core.md`) is simpler than a `data:` font embed.
86
113
 
@@ -118,6 +145,19 @@ The clip's `width` / `height` are the iframe's natural pixel dimensions — matc
118
145
  - The harness already applies a reset — `margin:0; padding:0; box-sizing:border-box; overflow:hidden`, and `body { background: transparent }` — so you mainly need to pin the explicit `width`/`height` on `html, body`. The body is transparent by default; it composites over the track below.
119
146
  - The iframe doesn't know about the clip — every internal coordinate is in iframe-pixel space.
120
147
 
148
+ ## 3D: what renders in preview and render
149
+
150
+ | Look | How | Preview and render |
151
+ |---|---|---|
152
+ | 2.5D camera move (layers at depth, dolly, rack focus) | `perspective` on a stage, `transform-style: preserve-3d` on a world, each layer at `translateZ(…)`. GSAP moves the world (`z`, `y`, `rotationY`) as the camera. Depth of field: `onUpdate` sets each layer's `blur()` from its distance to a tweened focus value | Match |
153
+ | Card flip, tilted plane | `perspective` + `rotationY` / `rotationX` | Match |
154
+ | Cubes, carousels, planes that cross | `preserve-3d` with several faces | Correct in the cloud render; Studio's preview paints faces in DOM order, so hidden faces show. Avoid |
155
+ | WebGL (three.js `WebGLRenderer`, shaders) | `<canvas>` with a WebGL context | Blank in the cloud render: no WebGL there |
156
+
157
+ For 2.5D, order layers back to front in the DOM and scale each far layer up so it still fills the frame (`scale = (perspective − z) / perspective`).
158
+
159
+ **Watch the cost.** Blur and `backdrop-filter` on full-frame layers are the expensive part on the CPU renderer: six blurred depth layers cost about 340 ms per 1080p frame. Keep blurred layers small, or blur only the layers that need it.
160
+
121
161
  ## Worked example: animated lower-third (GSAP)
122
162
 
123
163
  Slide-in name + role bar with subtle accent. **Clip sized to the bar (560×120), positioned via `offset`.** 5 seconds.
@@ -227,18 +267,19 @@ The same pattern scales to scene transitions (each scene is a `<section>` with i
227
267
 
228
268
  ## Common mistakes
229
269
 
230
- 1. **`<canvas>` elements.** The Studio frame capture serialises the iframe's DOM via `XMLSerializer`. A `<canvas>` element's bitmap lives in the 2D-context backing store, NOT in the DOM — so the cloned canvas comes through empty and **the captured frames show nothing where the canvas was**. The cloud render (puppeteer) handles canvas correctly, but Studio playback won't, so the parity is broken. Use SVG or positioned HTML elements instead:
270
+ 1. **`<canvas>` drawn in a loop.** Canvas 2D drawing renders in preview and render, but only when it's drawn from `window.__shotstackSeek(ms)`: a `requestAnimationFrame` loop never advances under seek. A WebGL canvas is blank in the cloud render. For charts and shapes, SVG is still lighter:
231
271
 
232
- | Want | Replace `<canvas>` with |
272
+ | Want | Use |
233
273
  |---|---|
234
- | Particles / generative graphics | Positioned `<div>` elements animated with CSS transforms, or `<circle>` in SVG |
274
+ | Particles / generative graphics | Canvas 2D from `__shotstackSeek` with seeded positions, or SVG `<circle>`s |
235
275
  | Charts (line / bar / area) | D3 → `<svg>` (see the bar-chart worked example) |
236
276
  | Pixel-level effects | CSS filters (`filter: blur(...) hue-rotate(...)`), `<feFilter>` in SVG |
237
277
  | Free-form drawings | SVG `<path>` |
238
-
239
- If you're building something that genuinely cannot be expressed without canvas, render it as a `<video>` or `<image>` asset instead of an `html5` clip.
240
278
  2. **Mismatched dimensions.** If `clip.width = 1920` and your CSS sets `body { width: 1280px }`, content gets cropped or stretched. Pin the iframe's `html, body` dimensions to the clip dimensions.
241
- 3. **JS syntax or runtime errors produce a blank clip with no render error.** If `asset.js` throws (syntax error or uncaught runtime error), the entire clip renders as a blank frame. The render still reports `status: "done"` with no error — there is no feedback loop. `shotstack validate` catches JS syntax errors offline; runtime errors (e.g. referencing a DOM element that doesn't exist) are silent. If a clip is blank, check the JS first: run `node --check` on the string, or wrap suspect code in `try/catch` to surface the error.
279
+ 3. **JS syntax or runtime errors produce a blank clip with no render error.** If `asset.js` throws (syntax error or uncaught runtime error), the entire clip renders as a blank frame. The render still reports `status: "done"` with no error — there is no feedback loop. Runtime errors (e.g. referencing a DOM element that doesn't exist) are silent: wrap suspect code in `try/catch` to surface the error.
280
+ <!-- surface:cli -->
281
+ `shotstack validate` catches JS syntax errors offline. If a clip is blank, check the JS first: run `node --check` on the string.
282
+ <!-- /surface -->
242
283
 
243
284
  ## When to use `html5` vs `rich-text`/`svg`
244
285