@shotstack/cli 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -1
- package/SKILL.md +59 -16
- package/dist/shotstack.js +44 -41
- package/package.json +3 -2
- package/references/fonts.md +25 -23
- package/references/html5-snippets.md +206 -0
- package/references/html5.md +252 -0
- package/references/ingest.md +121 -0
- package/references/motion.md +151 -0
- package/references/positioning.md +64 -0
- package/references/troubleshooting.md +8 -1
- package/shared/agent-core.md +262 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shotstack/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"dist/shotstack.js",
|
|
20
20
|
"SKILL.md",
|
|
21
|
+
"shared",
|
|
21
22
|
"references"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
@@ -28,7 +29,7 @@
|
|
|
28
29
|
"test": "bun test"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"@shotstack/schemas": "^1.
|
|
32
|
+
"@shotstack/schemas": "^1.12.2",
|
|
32
33
|
"commander": "^12.1.0",
|
|
33
34
|
"zod": "^4.4.3"
|
|
34
35
|
},
|
package/references/fonts.md
CHANGED
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The Shotstack render engine ships a small built-in font set; everything else must be loaded as a custom font via `timeline.fonts[]`. Per the [Shotstack docs](https://shotstack.io/docs/guide/architecting-an-application/rich-text.md), **prefer custom Google Fonts** for predictable rendering and full typographic range.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## CRITICAL: never fabricate font URLs
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Built-in fonts (fallback)
|
|
9
|
-
- Why system fonts (Arial, Helvetica) fail
|
|
7
|
+
**Do NOT construct or reconstruct Google Fonts URLs from memory.** Google rotates the version segment (`v26 → v31 → …`) and the hashed filename changes with each version, so any URL you assemble from training data is almost certainly a 404 — and a missing font fails the render. **Copy a URL verbatim** from the verified catalogue below, or from the live Studio SDK catalogue (linked further down). If the exact font you want isn't available verbatim, pick the closest verified match rather than inventing one.
|
|
10
8
|
|
|
11
9
|
## Custom-font workflow (preferred)
|
|
12
10
|
|
|
@@ -24,7 +22,7 @@ Load a font from a public URL via `timeline.fonts[]`, then reference its **file
|
|
|
24
22
|
"asset": {
|
|
25
23
|
"type": "rich-text",
|
|
26
24
|
"text": "Hello",
|
|
27
|
-
"font": { "family": "JTUSjIg1_i6t8kCHKm45xW5rygbi49c", "size": 60, "color": "#ffffff" }
|
|
25
|
+
"font": { "family": "JTUSjIg1_i6t8kCHKm45xW5rygbi49c", "size": 60, "weight": "700", "color": "#ffffff" }
|
|
28
26
|
},
|
|
29
27
|
"start": 0, "length": 3
|
|
30
28
|
}]
|
|
@@ -34,38 +32,42 @@ Load a font from a public URL via `timeline.fonts[]`, then reference its **file
|
|
|
34
32
|
}
|
|
35
33
|
```
|
|
36
34
|
|
|
37
|
-
The `font.family` value is the URL's filename without extension.
|
|
35
|
+
The `font.family` value is the URL's filename without extension, and it **must** match the URL's basename exactly. For URL `https://fonts.gstatic.com/s/montserrat/v31/JTUSjIg1_i6t8kCHKm45xW5rygbi49c.ttf`, family = `JTUSjIg1_i6t8kCHKm45xW5rygbi49c`. If `family` and the URL basename diverge, the font silently fails to load.
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
## Verified font catalogue (12 fonts)
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
Copied verbatim from the Studio SDK catalogue. Paste the **url** into `timeline.fonts[].src`, paste the **family** into `asset.font.family`.
|
|
42
40
|
|
|
43
|
-
|
|
41
|
+
| Font (style) | family (use in `font.family`) | url (use in `timeline.fonts[].src`) |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| Inter (sans, variable) | `UcCo3FwrK3iLTfvlaQc78lA2` | `https://fonts.gstatic.com/s/inter/v20/UcCo3FwrK3iLTfvlaQc78lA2.ttf` |
|
|
44
|
+
| Roboto (sans, variable) | `KFOmCnqEu92Fr1Me5WZLCzYlKw` | `https://fonts.gstatic.com/s/roboto/v50/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf` |
|
|
45
|
+
| Open Sans (sans, variable) | `mem8YaGs126MiZpBA-U1UpcaXcl0Aw` | `https://fonts.gstatic.com/s/opensans/v44/mem8YaGs126MiZpBA-U1UpcaXcl0Aw.ttf` |
|
|
46
|
+
| Montserrat (sans, variable) | `JTUSjIg1_i6t8kCHKm45xW5rygbi49c` | `https://fonts.gstatic.com/s/montserrat/v31/JTUSjIg1_i6t8kCHKm45xW5rygbi49c.ttf` |
|
|
47
|
+
| Poppins (sans) | `pxiEyp8kv8JHgFVrFJDUc1NECPY` | `https://fonts.gstatic.com/s/poppins/v24/pxiEyp8kv8JHgFVrFJDUc1NECPY.ttf` |
|
|
48
|
+
| DM Sans (sans, variable) | `rP2Hp2ywxg089UriOZSCHBeHFl0` | `https://fonts.gstatic.com/s/dmsans/v17/rP2Hp2ywxg089UriOZSCHBeHFl0.ttf` |
|
|
49
|
+
| Nunito (sans, variable) | `XRXV3I6Li01BKof4MuyAbsrVcA` | `https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKof4MuyAbsrVcA.ttf` |
|
|
50
|
+
| Raleway (sans, variable) | `1Ptug8zYS_SKggPN-CoCTqluHfE` | `https://fonts.gstatic.com/s/raleway/v37/1Ptug8zYS_SKggPN-CoCTqluHfE.ttf` |
|
|
51
|
+
| Oswald (display, variable) | `TK3iWkUHHAIjg75GHjUHte5fKg` | `https://fonts.gstatic.com/s/oswald/v57/TK3iWkUHHAIjg75GHjUHte5fKg.ttf` |
|
|
52
|
+
| Bebas Neue (display) | `JTUSjIg69CK48gW7PXooxW5rygbi49c` | `https://fonts.gstatic.com/s/bebasneue/v16/JTUSjIg69CK48gW7PXooxW5rygbi49c.ttf` |
|
|
53
|
+
| Anton (display) | `1Ptgg87LROyAm0K08i4gS7lu` | `https://fonts.gstatic.com/s/anton/v27/1Ptgg87LROyAm0K08i4gS7lu.ttf` |
|
|
54
|
+
| Playfair Display (serif, variable) | `nuFiD-vYSZviVYUb_rj3ij__anPXPTvSgWE_-xU` | `https://fonts.gstatic.com/s/playfairdisplay/v40/nuFiD-vYSZviVYUb_rj3ij__anPXPTvSgWE_-xU.ttf` |
|
|
44
55
|
|
|
45
|
-
|
|
56
|
+
Variable fonts (most of the above) cover the full weight range (100–900) from a single URL — set `font.weight` in the clip. For the non-variable fonts (Poppins, Bebas Neue, Anton) the registered URL is weight 400; the SDK falls back gracefully when you request a bolder weight.
|
|
46
57
|
|
|
47
|
-
|
|
48
|
-
|---|---|
|
|
49
|
-
| Montserrat 700 | `https://fonts.gstatic.com/s/montserrat/v31/JTUSjIg1_i6t8kCHKm45xW5rygbi49c.ttf` |
|
|
50
|
-
| Roboto 400 | `https://fonts.gstatic.com/s/roboto/v48/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.ttf` |
|
|
51
|
-
| Inter 700 | `https://fonts.gstatic.com/s/inter/v20/UcCo3FwrK3iLTfvlaQc78lA2.ttf` |
|
|
52
|
-
| Bangers | `https://fonts.gstatic.com/s/bangers/v25/FeVQS0BTqb0h60ACL5la2bxii28.ttf` |
|
|
53
|
-
| Luckiest Guy | `https://fonts.gstatic.com/s/luckiestguy/v25/_gP_1RrxsjcxVyin9l9n_j2RStR3qDpraA.ttf` |
|
|
54
|
-
| Open Sans 400 | `https://fonts.gstatic.com/s/opensans/v40/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVc.ttf` |
|
|
55
|
-
| Poppins 700 | `https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLCz7Z1xlFQ.ttf` |
|
|
58
|
+
### Sourcing more fonts
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
The Studio SDK ships a curated catalogue of ~400 Google Fonts at <https://github.com/shotstack/shotstack-studio-sdk/blob/main/src/core/fonts/google-fonts.ts>. Copy the `url` and `family` for an entry **verbatim** — do not hand-edit the version or hash. For a font not in that catalogue, open it on <https://fonts.google.com>, view the CSS, and copy the exact `.ttf` URL from the `@font-face` `src` (again, verbatim).
|
|
58
61
|
|
|
59
62
|
### Multiple weights / styles
|
|
60
63
|
|
|
61
|
-
Each weight or style is a separate URL — load
|
|
64
|
+
Each weight or style is a **separate URL** — load every one you reference. Look each up in the SDK catalogue rather than guessing the hash:
|
|
62
65
|
|
|
63
66
|
```json
|
|
64
67
|
{
|
|
65
68
|
"timeline": {
|
|
66
69
|
"fonts": [
|
|
67
|
-
{ "src": "https://fonts.gstatic.com/s/roboto/
|
|
68
|
-
{ "src": "https://fonts.gstatic.com/s/roboto/v48/KFOlCnqEu92Fr1MmEU9fBBc4.ttf" }
|
|
70
|
+
{ "src": "https://fonts.gstatic.com/s/roboto/v50/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf" }
|
|
69
71
|
]
|
|
70
72
|
}
|
|
71
73
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# HTML5 snippet pack — drop-in motion graphics
|
|
2
|
+
|
|
3
|
+
Copy-paste `html5` clips that "pop" — kinetic type, count-ups, shine sweeps,
|
|
4
|
+
pulsing CTAs, grain. Each is a **single clip**: paste it into a track's
|
|
5
|
+
`clips[]`, set `start`/`length`, and position with `offset`.
|
|
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.
|
|
14
|
+
|
|
15
|
+
Read [`html5.md`](html5.md) for the rules these obey. The non-negotiables:
|
|
16
|
+
|
|
17
|
+
- **Seekable animation only.** GSAP timelines, GSAP tweens (incl. `onUpdate`), 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
|
+
- **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`/`%`.
|
|
19
|
+
- **The body is transparent by default** — the clip composites over the layers below (only set an opaque background if you want one).
|
|
20
|
+
- **No `<canvas>`** — Studio capture serialises the DOM; canvas bitmaps come through empty. Use SVG or positioned DOM.
|
|
21
|
+
- **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.
|
|
22
|
+
- **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`.
|
|
23
|
+
|
|
24
|
+
Coordinates below assume a **1080×1920 vertical** canvas; adjust `offset` for other sizes.
|
|
25
|
+
|
|
26
|
+
> **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.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. Blur reveal — calm text entrance
|
|
31
|
+
|
|
32
|
+
**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
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"asset": {
|
|
39
|
+
"type": "html5",
|
|
40
|
+
"html": "<div class=\"t\">{{title}}</div>",
|
|
41
|
+
"css": "html,body{margin:0;width:900px;height:300px;overflow:hidden;background:transparent;font-family:'Clash Display',system-ui,sans-serif}.t{display:flex;align-items:center;justify-content:center;width:900px;height:300px;font-size:150px;font-weight:600;letter-spacing:-3px;color:#141414;text-align:center}",
|
|
42
|
+
"js": "gsap.from('.t',{opacity:0,y:16,filter:'blur(10px)',duration:0.6,ease:'power3.out'});gsap.to({},{duration:1.5});"
|
|
43
|
+
},
|
|
44
|
+
"start": 0,
|
|
45
|
+
"length": 3,
|
|
46
|
+
"width": 900,
|
|
47
|
+
"height": 300,
|
|
48
|
+
"offset": { "x": 0, "y": 0 }
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`base` (0.6 s) entrance on `power3.out`, then a `hold` (1.5 s) settle. Swap `{{title}}` via top-level `merge[]`. `Clash Display` falls back to `system-ui` unless you inline it as a `data:` `@font-face` (see Fonts above).
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 2. Kinetic headline (word-by-word rise)
|
|
57
|
+
|
|
58
|
+
**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
|
|
59
|
+
|
|
60
|
+
Each word springs up in sequence. GSAP timeline, fully seekable, on the house stagger.
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"asset": {
|
|
65
|
+
"type": "html5",
|
|
66
|
+
"html": "<div class=\"h\"><span>JUST</span> <span>DROPPED</span></div>",
|
|
67
|
+
"css": "html,body{margin:0;width:980px;height:420px;overflow:hidden;background:transparent;font-family:'Anton',system-ui,sans-serif}.h{display:flex;flex-wrap:wrap;gap:0 18px;align-items:center;justify-content:center;width:980px;height:420px;text-transform:uppercase;line-height:0.9}.h span{display:inline-block;font-size:150px;font-weight:800;color:#141414;transform:translateY(120%);opacity:0}",
|
|
68
|
+
"js": "const tl=gsap.timeline();gsap.utils.toArray('.h span').forEach((el,i)=>{tl.to(el,{y:0,opacity:1,duration:0.6,ease:'power3.out'},i*0.13)});tl.to({},{duration:1.5});"
|
|
69
|
+
},
|
|
70
|
+
"start": 0,
|
|
71
|
+
"length": 3,
|
|
72
|
+
"width": 980,
|
|
73
|
+
"height": 420,
|
|
74
|
+
"offset": { "x": 0, "y": 0.02 }
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 3. Count-up number / price odometer
|
|
83
|
+
|
|
84
|
+
**Category** data · **Use when** revealing a value, price, stat or metric · **Canvas** 620×220 · **Tags** number, count, price, data · **Merge-friendly** target value
|
|
85
|
+
|
|
86
|
+
Animate a value from 0 to its target. Seek-safe because the count lives in a **tweened object with `onUpdate`** (fires on seek), never a timer.
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"asset": {
|
|
91
|
+
"type": "html5",
|
|
92
|
+
"html": "<div class=\"wrap\"><span class=\"cur\">$</span><span id=\"n\">0</span></div>",
|
|
93
|
+
"css": "html,body{margin:0;width:620px;height:220px;overflow:hidden;background:transparent;font-family:system-ui,sans-serif}.wrap{display:flex;align-items:baseline;justify-content:center;width:620px;height:220px;color:#141414;font-weight:800;font-variant-numeric:tabular-nums}.cur{font-size:70px;margin-right:6px}#n{font-size:150px;letter-spacing:-2px}",
|
|
94
|
+
"js": "const o={v:0};const out=document.getElementById('n');const tl=gsap.timeline();tl.to(o,{v:395,duration:0.8,ease:'power2.out',onUpdate:()=>{out.textContent=Math.round(o.v)}});tl.to({},{duration:1.5});"
|
|
95
|
+
},
|
|
96
|
+
"start": 0,
|
|
97
|
+
"length": 3,
|
|
98
|
+
"width": 620,
|
|
99
|
+
"height": 220,
|
|
100
|
+
"offset": { "x": 0, "y": -0.1 }
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`slow` (0.8 s) count on `power2.out` (a decelerating settle reads right for a value), then a `hold`. Swap `v:395` for any target; for thousands separators use `Math.round(o.v).toLocaleString()`. Pair with a static label on an adjacent `rich-text` track ("FROM", "AUD").
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 4. Shine / gloss sweep
|
|
109
|
+
|
|
110
|
+
**Category** graphics (emphasis) · **Use when** adding premium gloss to a product name, logo or card · **Canvas** 860×280 · **Tags** shine, gloss, emphasis, specular
|
|
111
|
+
|
|
112
|
+
A specular highlight slides across text — premium product gloss. *Continuous* motion (the sweep itself), so it uses `ease-in-out` over `slower` (1.0 s), not an entrance token. Pure CSS keyframe (WAAPI-seekable). Put this clip **above** the thing it shines on, or wrap the content in the same clip.
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"asset": {
|
|
117
|
+
"type": "html5",
|
|
118
|
+
"html": "<div class=\"plate\"><div class=\"label\">UTOPIA</div><div class=\"shine\"></div></div>",
|
|
119
|
+
"css": "html,body{margin:0;width:860px;height:280px;overflow:hidden;background:transparent;font-family:'Anton',system-ui,sans-serif}.plate{position:relative;width:860px;height:280px;display:flex;align-items:center;justify-content:center;overflow:hidden}.label{font-size:200px;font-weight:800;letter-spacing:6px;color:#141414;text-transform:uppercase}.shine{position:absolute;top:0;left:0;width:240px;height:280px;background:linear-gradient(100deg,transparent,rgba(255,255,255,0.85),transparent);mix-blend-mode:overlay;transform:translateX(-300px) skewX(-12deg);animation:sweep 1s ease-in-out 0.4s 1 both}@keyframes sweep{to{transform:translateX(1100px) skewX(-12deg)}}",
|
|
120
|
+
"js": ""
|
|
121
|
+
},
|
|
122
|
+
"start": 0,
|
|
123
|
+
"length": 3,
|
|
124
|
+
"width": 860,
|
|
125
|
+
"height": 280,
|
|
126
|
+
"offset": { "x": 0, "y": 0 }
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`mix-blend-mode: overlay` makes the sweep read as a real highlight rather than a white bar. No JS needed. The `0.4 s` start delay lets the thing it shines on settle first.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 5. Pulsing CTA button
|
|
135
|
+
|
|
136
|
+
**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`
|
|
137
|
+
|
|
138
|
+
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.
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"asset": {
|
|
143
|
+
"type": "html5",
|
|
144
|
+
"html": "<div class=\"cta\">SHOP NOW</div>",
|
|
145
|
+
"css": "html,body{margin:0;width:620px;height:170px;overflow:hidden;background:transparent;font-family:system-ui,sans-serif}.cta{width:560px;height:128px;margin:21px auto;display:flex;align-items:center;justify-content:center;border-radius:16px;background:#D96B82;color:#fff;font-size:46px;font-weight:700;letter-spacing:4px;text-transform:uppercase;box-shadow:0 0 0 0 rgba(217,107,130,0.5);animation:pulse 1.6s ease-in-out 0s infinite both}@keyframes pulse{0%,100%{transform:scale(1);box-shadow:0 0 0 0 rgba(217,107,130,0.45)}50%{transform:scale(1.04);box-shadow:0 0 36px 8px rgba(217,107,130,0.35)}}",
|
|
146
|
+
"js": ""
|
|
147
|
+
},
|
|
148
|
+
"start": 0,
|
|
149
|
+
"length": 4,
|
|
150
|
+
"width": 620,
|
|
151
|
+
"height": 170,
|
|
152
|
+
"offset": { "x": 0, "y": -0.4 }
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
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`.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 6. Film-grain / texture overlay
|
|
161
|
+
|
|
162
|
+
**Category** atmosphere · **Use when** adding a moody analogue texture over a dark scene · **Canvas** 1080×1920 (full frame) · **Tags** grain, texture, overlay, atmosphere
|
|
163
|
+
|
|
164
|
+
A subtle moving grain over the whole frame. *Continuous drift* — `steps()`/`linear` is correct here (not an entrance). SVG `feTurbulence`, animated by shifting a slightly-oversized layer so it never reveals an edge. Size this one **to the canvas** and put it on a top track at low opacity.
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"asset": {
|
|
169
|
+
"type": "html5",
|
|
170
|
+
"html": "<div class=\"grain\"></div>",
|
|
171
|
+
"css": "html,body{margin:0;width:1080px;height:1920px;overflow:hidden;background:transparent}.grain{position:absolute;top:-5%;left:-5%;width:110%;height:110%;opacity:0.12;background-image:url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='200' height='200' filter='url(%23n)'/></svg>\");background-size:240px 240px;animation:drift 0.6s steps(4) 0s infinite both}@keyframes drift{0%{transform:translate(0,0)}25%{transform:translate(-12px,8px)}50%{transform:translate(10px,-10px)}75%{transform:translate(-8px,-6px)}100%{transform:translate(0,0)}}",
|
|
172
|
+
"js": ""
|
|
173
|
+
},
|
|
174
|
+
"start": 0,
|
|
175
|
+
"length": 10,
|
|
176
|
+
"width": 1080,
|
|
177
|
+
"height": 1920,
|
|
178
|
+
"offset": { "x": 0, "y": 0 }
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
A `data:` URI is fine **inside an html5 asset's CSS** (it's iframe content, not an `asset.src` the render workers fetch). Tune `opacity` (0.06–0.18) and `baseFrequency` (higher = finer grain). `steps(4)` gives a filmic stutter rather than smooth slide.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Brand kit — re-skin every snippet at once
|
|
187
|
+
|
|
188
|
+
The snippets share one palette (ink `#141414`, accent `#D96B82`) so a set already looks coherent. To re-skin a whole edit to a brand in one place, lift the colours/font into top-level `merge[]` and reference the tokens in each clip's `css` — `merge` find/replace runs over the `html`/`css` strings too:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
"merge": [
|
|
192
|
+
{ "find": "ink", "replace": "#1A1A2E" },
|
|
193
|
+
{ "find": "accent", "replace": "#E8552D" },
|
|
194
|
+
{ "find": "font", "replace": "Anton, system-ui, sans-serif" }
|
|
195
|
+
]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
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.)
|
|
199
|
+
|
|
200
|
+
## Composing these
|
|
201
|
+
|
|
202
|
+
- 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.
|
|
203
|
+
- They don't overlap on a single track, so `shotstack validate <file>` stays clean. Run it before rendering.
|
|
204
|
+
- Reuse text via top-level `merge[]` (`{{title}}` in the HTML) — see the lower-third example in `html5.md`.
|
|
205
|
+
- Mix calm and punchy deliberately: a `blur-reveal` title, a `kinetic-headline` hero line, a `count-up` stat, a `shine` on the product, a pulsing CTA — all on the same tokens, so the set reads as one piece.
|
|
206
|
+
- Heavier motion = longer render. Preview in `shotstack studio <file>` before spending credits.
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# HTML5 asset guide
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
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
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- Required and optional fields
|
|
10
|
+
- Preloaded libraries
|
|
11
|
+
- The browser harness (deterministic auto-seek)
|
|
12
|
+
- Sandbox restrictions: no network, inline everything (incl. fonts)
|
|
13
|
+
- Sizing
|
|
14
|
+
- Worked example: animated lower-third (GSAP)
|
|
15
|
+
- Worked example: animated bar chart (D3 + GSAP)
|
|
16
|
+
- Worked example: 10-second countdown (pure CSS)
|
|
17
|
+
- Common mistakes
|
|
18
|
+
- When to use `html5` vs `rich-text`/`svg`
|
|
19
|
+
|
|
20
|
+
## Required and optional fields
|
|
21
|
+
|
|
22
|
+
| Field | Required | Type | Notes |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| `type` | Yes | `"html5"` | Discriminator. |
|
|
25
|
+
| `html` | Yes | string | Body markup. Supports merge fields (`{{title}}`). |
|
|
26
|
+
| `css` | No | string | Stylesheet. Inlined into the iframe `<head>`. |
|
|
27
|
+
| `js` | No | string | Script. Runs after libraries are preloaded. |
|
|
28
|
+
|
|
29
|
+
Clip-level `width` and `height` set the iframe's pixel dimensions. They default to the edit's natural size.
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"asset": {
|
|
34
|
+
"type": "html5",
|
|
35
|
+
"html": "<div class=\"card\"><h1>{{title}}</h1></div>",
|
|
36
|
+
"css": ".card { font-family: 'Inter'; padding: 32px; color: #fff; }",
|
|
37
|
+
"js": "gsap.to('.card', { x: 200, duration: 1 });"
|
|
38
|
+
},
|
|
39
|
+
"start": 0,
|
|
40
|
+
"length": 4,
|
|
41
|
+
"width": 1920,
|
|
42
|
+
"height": 1080
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Preloaded libraries
|
|
47
|
+
|
|
48
|
+
Four libraries are always available — no `<script src=>` tags needed:
|
|
49
|
+
|
|
50
|
+
- **GSAP** (`window.gsap`) — primary animation library. Use timelines (`gsap.timeline()`) over loose tweens; the harness seeks timelines correctly.
|
|
51
|
+
- **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).
|
|
54
|
+
|
|
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).
|
|
56
|
+
|
|
57
|
+
## The browser harness (deterministic auto-seek)
|
|
58
|
+
|
|
59
|
+
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 an `onUpdate` tween (see the count-up snippet).
|
|
60
|
+
|
|
61
|
+
**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`.
|
|
62
|
+
|
|
63
|
+
## Sandbox restrictions: no network, inline everything
|
|
64
|
+
|
|
65
|
+
The iframe renders under a strict Content-Security-Policy (`default-src 'none'`), in both Studio preview and cloud render. Nothing is fetched from the network at render time:
|
|
66
|
+
|
|
67
|
+
- **No external `<script src=>`.** Only the bundled GSAP/anime.js/D3/Lottie run (they're inlined for you). Another library would have to be inlined into your `js` — and must be seekable.
|
|
68
|
+
- **No `fetch` / `XMLHttpRequest`** (`connect-src 'none'`). Bundle any data inline — e.g. the JS array in the bar-chart example.
|
|
69
|
+
- **No remote images** (`img-src 'self' data: blob:`). Embed images as `data:` URIs. A `data:image/svg+xml` background is fine.
|
|
70
|
+
- **No remote fonts** (`font-src 'self' data:`) — see below.
|
|
71
|
+
|
|
72
|
+
### Fonts in HTML5
|
|
73
|
+
|
|
74
|
+
`timeline.fonts[]` does **not** apply to `html5` assets (it loads fonts for `rich-text` / `rich-caption` only), and a remote `@font-face` URL (Google Fonts, etc.) is CSP-blocked. Two ways to get a custom font into an `html5` clip:
|
|
75
|
+
|
|
76
|
+
1. **Inline it as a `data:` `@font-face`** — base64-encode the `.woff2` / `.ttf`:
|
|
77
|
+
```css
|
|
78
|
+
@font-face { font-family: 'Brand'; src: url('data:font/woff2;base64,<…>') format('woff2'); }
|
|
79
|
+
.title { font-family: 'Brand', sans-serif; }
|
|
80
|
+
```
|
|
81
|
+
2. **Use a system family** (`system-ui`, `Arial`, `Georgia`, …) — resolves in the render browser with no load.
|
|
82
|
+
|
|
83
|
+
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 `references/fonts.md`) is simpler than a `data:` font embed.
|
|
84
|
+
|
|
85
|
+
## Sizing
|
|
86
|
+
|
|
87
|
+
The single most important rule: **size the clip to the content, not to the canvas.** Then position with `offset`.
|
|
88
|
+
|
|
89
|
+
The clip's `width` / `height` are the iframe's natural pixel dimensions — match them to the content's real size; every coordinate inside the iframe is in that pixel space. Sizing the clip to the whole canvas when the content is a small corner overlay wastes space and makes it harder to place — size to the content and move it with `offset`.
|
|
90
|
+
|
|
91
|
+
**Right pattern:**
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"asset": {
|
|
96
|
+
"type": "html5",
|
|
97
|
+
"html": "<div class=\"bar\">…</div>",
|
|
98
|
+
"css": "html,body{margin:0;padding:0;width:560px;height:120px;background:transparent;overflow:hidden}.bar{width:560px;height:120px;…}"
|
|
99
|
+
},
|
|
100
|
+
"width": 560,
|
|
101
|
+
"height": 120,
|
|
102
|
+
"offset": { "x": -0.29, "y": -0.33 }
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- Clip `width` / `height` matches the content's natural size (560 × 120 for a typical lower-third bar)
|
|
107
|
+
- CSS `html, body` matches the clip dimensions exactly
|
|
108
|
+
- The `.bar` (or whatever the root visual is) fills the body — no `position: absolute; left: …` inside the iframe
|
|
109
|
+
- `offset` positions the clip on the canvas (`{x: 0, y: 0}` is centred; `x` ±0.5 is the canvas edges; `y` positive is up, negative is down)
|
|
110
|
+
|
|
111
|
+
**Reserved for the canvas-spanning case:** sizing the clip to the full output (`width: 1920, height: 1080`) is correct only when the content really fills the frame — animated charts, scene transitions, full-screen titles. For overlays (lower-thirds, badges, watermarks, callouts), always size to the content.
|
|
112
|
+
|
|
113
|
+
**General rules either way:**
|
|
114
|
+
|
|
115
|
+
- Use **fixed pixel values throughout** (`px`, not `vw` / `vh` / `%` on root). The capture happens at the iframe's natural size, not a viewport.
|
|
116
|
+
- 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.
|
|
117
|
+
- The iframe doesn't know about the clip — every internal coordinate is in iframe-pixel space.
|
|
118
|
+
|
|
119
|
+
## Worked example: animated lower-third (GSAP)
|
|
120
|
+
|
|
121
|
+
Slide-in name + role bar with subtle accent. **Clip sized to the bar (560×120), positioned via `offset`.** 5 seconds.
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"timeline": {
|
|
126
|
+
"tracks": [
|
|
127
|
+
{
|
|
128
|
+
"clips": [
|
|
129
|
+
{
|
|
130
|
+
"asset": {
|
|
131
|
+
"type": "html5",
|
|
132
|
+
"html": "<div class=\"bar\"><div class=\"accent\"></div><div class=\"text\"><div class=\"name\" id=\"name\">{{name}}</div><div class=\"role\" id=\"role\">{{role}}</div></div></div>",
|
|
133
|
+
"css": "html,body{margin:0;padding:0;width:560px;height:120px;overflow:hidden;font-family:system-ui,sans-serif;background:transparent}.bar{display:flex;align-items:center;width:560px;height:120px;padding:0 32px;box-sizing:border-box;background:rgba(15,23,42,0.92);border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,0.45);opacity:0}.accent{width:6px;height:72px;background:linear-gradient(180deg,#22d3ee,#a78bfa);border-radius:3px;margin-right:24px;transform:scaleY(0);transform-origin:top}.text{display:flex;flex-direction:column;color:#fff}.name{font-size:44px;font-weight:700;letter-spacing:-0.5px;opacity:0;transform:translateX(-12px)}.role{font-size:22px;font-weight:500;color:#94a3b8;margin-top:4px;opacity:0;transform:translateX(-12px)}",
|
|
134
|
+
"js": "const tl=gsap.timeline();tl.to('.bar',{opacity:1,duration:0.5,ease:'power2.out'},0).to('.accent',{scaleY:1,duration:0.5,ease:'power3.out'},0.2).to('#name',{opacity:1,x:0,duration:0.5,ease:'power2.out'},0.35).to('#role',{opacity:1,x:0,duration:0.5,ease:'power2.out'},0.5).to({},{duration:3.5}).to('.bar',{opacity:0,duration:0.5,ease:'power2.in'});"
|
|
135
|
+
},
|
|
136
|
+
"start": 0,
|
|
137
|
+
"length": 5,
|
|
138
|
+
"width": 560,
|
|
139
|
+
"height": 120,
|
|
140
|
+
"offset": { "x": -0.29, "y": -0.39 }
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
},
|
|
146
|
+
"merge": [
|
|
147
|
+
{ "find": "name", "replace": "Sarah Chen" },
|
|
148
|
+
{ "find": "role", "replace": "Head of Product" }
|
|
149
|
+
],
|
|
150
|
+
"output": { "format": "mp4", "resolution": "1080" }
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Patterns to copy:**
|
|
155
|
+
|
|
156
|
+
- **Clip is the size of the bar, not the canvas** — placement is one `offset` change.
|
|
157
|
+
- **`html, body, .bar` all 560×120.** No absolute positioning inside the iframe — the bar IS the iframe content.
|
|
158
|
+
- One GSAP timeline drives every animation.
|
|
159
|
+
- Merge fields (`{{name}}`, `{{role}}`) in the HTML, populated by **top-level** `merge[]` (sibling of `timeline`/`output`, NOT a clip property). Keeps the asset reusable.
|
|
160
|
+
- A trailing `.to({}, { duration: 3.5 })` holds the final state before the clip ends.
|
|
161
|
+
|
|
162
|
+
## Worked example: animated bar chart (D3 + GSAP)
|
|
163
|
+
|
|
164
|
+
D3 builds the SVG; GSAP animates the bars growing in. 6 seconds, 1080p.
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"timeline": {
|
|
169
|
+
"tracks": [
|
|
170
|
+
{
|
|
171
|
+
"clips": [
|
|
172
|
+
{
|
|
173
|
+
"asset": {
|
|
174
|
+
"type": "html5",
|
|
175
|
+
"html": "<div class=\"stage\"><h1>Sessions by country</h1><div class=\"sub\">Last 30 days</div><svg id=\"chart\" width=\"1600\" height=\"640\" viewBox=\"0 0 1600 640\"><defs><linearGradient id=\"g\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\"><stop offset=\"0%\" stop-color=\"#22d3ee\"/><stop offset=\"100%\" stop-color=\"#0891b2\"/></linearGradient></defs></svg></div>",
|
|
176
|
+
"css": "html,body{margin:0;padding:0;width:1920px;height:1080px;overflow:hidden;font-family:system-ui,sans-serif;background:radial-gradient(ellipse at 20% 0%,#0f172a 0%,#03020b 100%);color:#e2e8f0}.stage{position:relative;width:1920px;height:1080px;padding:96px 160px;box-sizing:border-box}h1{margin:0;font-size:64px;font-weight:800;letter-spacing:-2px;color:#fff;opacity:0}.sub{margin-top:12px;font-size:24px;color:#94a3b8;opacity:0}#chart{position:absolute;top:280px;left:160px;opacity:0}.bar{fill:url(#g)}.label{font-size:22px;font-weight:700;fill:#fff;font-variant-numeric:tabular-nums}.cat{font-size:18px;fill:#94a3b8;text-transform:uppercase;letter-spacing:1px}",
|
|
177
|
+
"js": "const data=[{c:'US',v:48230},{c:'IN',v:32140},{c:'GB',v:21670},{c:'DE',v:18450},{c:'BR',v:15890},{c:'JP',v:14210},{c:'AU',v:11630}];const W=1600,H=640,M={t:20,r:120,b:60,l:120};const iw=W-M.l-M.r,ih=H-M.t-M.b;const x=d3.scaleBand().domain(data.map(d=>d.c)).range([0,iw]).padding(0.28);const y=d3.scaleLinear().domain([0,d3.max(data,d=>d.v)*1.05]).range([ih,0]);const g=d3.select('#chart').append('g').attr('transform',`translate(${M.l},${M.t})`);const bars=g.selectAll('rect').data(data).enter().append('rect').attr('class','bar').attr('x',d=>x(d.c)).attr('y',ih).attr('width',x.bandwidth()).attr('height',0).attr('rx',8);const labels=g.selectAll('text.label').data(data).enter().append('text').attr('class','label').attr('x',d=>x(d.c)+x.bandwidth()/2).attr('y',ih).attr('text-anchor','middle').text(d=>d.v.toLocaleString()).style('opacity',0);const cats=g.selectAll('text.cat').data(data).enter().append('text').attr('class','cat').attr('x',d=>x(d.c)+x.bandwidth()/2).attr('y',ih+34).attr('text-anchor','middle').text(d=>d.c);const tl=gsap.timeline();tl.to('h1',{opacity:1,duration:0.6,ease:'power3.out'},0.1).to('.sub',{opacity:1,duration:0.5,ease:'power2.out'},0.5).to('#chart',{opacity:1,duration:0.4,ease:'power2.out'},0.7);bars.each(function(d,i){tl.to(this,{attr:{y:y(d.v),height:ih-y(d.v)},duration:0.7,ease:'power2.out'},1.0+i*0.08)});labels.each(function(d,i){tl.to(this,{attr:{y:y(d.v)-14},opacity:1,duration:0.4,ease:'power2.out'},1.4+i*0.08)});"
|
|
178
|
+
},
|
|
179
|
+
"start": 0,
|
|
180
|
+
"length": 6,
|
|
181
|
+
"width": 1920,
|
|
182
|
+
"height": 1080
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
]
|
|
187
|
+
},
|
|
188
|
+
"output": { "format": "mp4", "resolution": "1080" }
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Patterns to copy:**
|
|
193
|
+
|
|
194
|
+
- D3 builds DOM; **GSAP animates it**. D3's `.transition()` works but GSAP is the more reliable seek target.
|
|
195
|
+
- Bars start at `y = ih, height = 0` (collapsed at the baseline); GSAP grows them up via `attr: { y, height }`.
|
|
196
|
+
- Stagger via `1.0 + i * 0.08` per bar — each bar starts 80ms after the previous.
|
|
197
|
+
|
|
198
|
+
## Worked example: 10-second countdown (pure CSS)
|
|
199
|
+
|
|
200
|
+
A countdown is "show different content at different times" — the classic case where authors reach for `setTimeout` or `gsap.call()`. **Both are wrong** because the capture harness seeks by absolute time, not by playing through real time. The right pattern: render every state up-front, hide them with `opacity:0`, and use one CSS animation per state with staggered `animation-delay`.
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"asset": {
|
|
205
|
+
"type": "html5",
|
|
206
|
+
"html": "<div class=\"stage\"><div id=\"n10\" class=\"num\">10</div><div id=\"n9\" class=\"num\">9</div><div id=\"n8\" class=\"num\">8</div><div id=\"n7\" class=\"num\">7</div><div id=\"n6\" class=\"num\">6</div><div id=\"n5\" class=\"num\">5</div><div id=\"n4\" class=\"num\">4</div><div id=\"n3\" class=\"num\">3</div><div id=\"n2\" class=\"num\">2</div><div id=\"n1\" class=\"num\">1</div></div>",
|
|
207
|
+
"css": "html,body{margin:0;width:1920px;height:1080px;background:#000;font-family:system-ui,sans-serif;overflow:hidden}.stage{position:relative;width:1920px;height:1080px}.num{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:280px;font-weight:900;color:#fff;opacity:0}@keyframes pop{0%,100%{opacity:0;transform:scale(1.3)}10%,90%{opacity:1;transform:scale(1)}}#n10{animation:pop 1s linear 0s 1 both}#n9{animation:pop 1s linear 1s 1 both}#n8{animation:pop 1s linear 2s 1 both}#n7{animation:pop 1s linear 3s 1 both}#n6{animation:pop 1s linear 4s 1 both}#n5{animation:pop 1s linear 5s 1 both}#n4{animation:pop 1s linear 6s 1 both}#n3{animation:pop 1s linear 7s 1 both}#n2{animation:pop 1s linear 8s 1 both}#n1{animation:pop 1s linear 9s 1 both}"
|
|
208
|
+
},
|
|
209
|
+
"start": 0,
|
|
210
|
+
"length": 10,
|
|
211
|
+
"width": 1920,
|
|
212
|
+
"height": 1080
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
No JS at all — each number is a CSS animation in its own time slot, so every captured frame shows whichever number is mid-pop.
|
|
217
|
+
|
|
218
|
+
**Patterns to copy:**
|
|
219
|
+
|
|
220
|
+
- **One element per state.** `<div id="n10">10</div>`, `<div id="n9">9</div>`, ... — never mutate `textContent` from JS.
|
|
221
|
+
- **Stagger via `animation-delay`.** `0s, 1s, 2s, ...` lines each animation up to a unique slot.
|
|
222
|
+
- **`animation-fill-mode: both`.** During the delay phase the element shows the `0%` keyframe (`opacity:0`); after the animation it shows the `100%` keyframe (`opacity:0`). Together with `position:absolute;inset:0`, only the currently-animating element is visible.
|
|
223
|
+
|
|
224
|
+
The same pattern scales to scene transitions (each scene is a `<section>` with its own animation slot), tickers (each price is a `<div>` with a slot), animated lists, etc.
|
|
225
|
+
|
|
226
|
+
## Common mistakes
|
|
227
|
+
|
|
228
|
+
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:
|
|
229
|
+
|
|
230
|
+
| Want | Replace `<canvas>` with |
|
|
231
|
+
|---|---|
|
|
232
|
+
| Particles / generative graphics | Positioned `<div>` elements animated with CSS transforms, or `<circle>` in SVG |
|
|
233
|
+
| Charts (line / bar / area) | D3 → `<svg>` (see the bar-chart worked example) |
|
|
234
|
+
| Pixel-level effects | CSS filters (`filter: blur(...) hue-rotate(...)`), `<feFilter>` in SVG |
|
|
235
|
+
| Free-form drawings | SVG `<path>` |
|
|
236
|
+
|
|
237
|
+
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.
|
|
238
|
+
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.
|
|
239
|
+
|
|
240
|
+
## When to use `html5` vs `rich-text`/`svg`
|
|
241
|
+
|
|
242
|
+
| Need | Use |
|
|
243
|
+
|---|---|
|
|
244
|
+
| Single line of styled text with a font, colour, and position | `rich-text` (faster, no iframe overhead) |
|
|
245
|
+
| A few static shapes (rectangles, circles, paths) | `svg` (lighter than HTML5) |
|
|
246
|
+
| Animated motion graphics (timeline of fades/slides/scales) | `html5` with GSAP |
|
|
247
|
+
| Data visualisation (charts, dashboards, mock UIs) | `html5` with D3 + GSAP |
|
|
248
|
+
| Lottie animation | `html5` with `lottie.loadAnimation(...)` |
|
|
249
|
+
| Animated text effects beyond what `rich-text` offers | `html5` with GSAP |
|
|
250
|
+
| Logo lockup with text + shapes (no animation) | `rich-text` + `svg` on adjacent tracks |
|
|
251
|
+
|
|
252
|
+
Reach for `html5` when the alternative would be an unwieldy stack of separate tracks or when the design genuinely needs DOM-style layout (flexbox, grid, layered backgrounds with shadows). For static or single-property animation, the lighter assets render faster.
|