@shotstack/cli 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotstack/cli",
3
- "version": "0.3.2",
3
+ "version": "0.5.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",
@@ -28,7 +28,9 @@
28
28
  "test": "bun test"
29
29
  },
30
30
  "dependencies": {
31
- "commander": "^12.1.0"
31
+ "@shotstack/schemas": "^1.12.2",
32
+ "commander": "^12.1.0",
33
+ "zod": "^4.4.3"
32
34
  },
33
35
  "devDependencies": {
34
36
  "@types/bun": "latest",
@@ -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
- ## Contents
5
+ ## CRITICAL: never fabricate font URLs
6
6
 
7
- - The custom-font workflow (preferred)
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
- For URL `https://fonts.gstatic.com/s/montserrat/v31/JTUSjIg1_i6t8kCHKm45xW5rygbi49c.ttf`, family = `JTUSjIg1_i6t8kCHKm45xW5rygbi49c`.
37
+ ## Verified font catalogue (12 fonts)
40
38
 
41
- ### Where to find Google Fonts URLs
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
- The Shotstack 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>. Each entry has a fastest-served Google Fonts CDN URL.
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
- Common picks:
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
- | Font | URL |
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
- For other fonts, find the family on <https://fonts.google.com>, view the CSS, and copy the `.ttf` URL from the `@font-face` `src`.
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 each one you need:
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/v48/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.ttf" },
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,145 @@
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
+ Read [`html5.md`](html5.md) first for the rules these obey. The non-negotiables:
8
+
9
+ - **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.
10
+ - **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`/`%`.
11
+ - **The body is transparent by default** — the clip composites over the layers below (only set an opaque background if you want one).
12
+ - **No `<canvas>`** — Studio capture serialises the DOM; canvas bitmaps come through empty. Use SVG or positioned DOM.
13
+ - **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.
14
+ - **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`.
15
+
16
+ Coordinates below assume a **1080×1920 vertical** canvas; adjust `offset` for other sizes. Colours use a neutral ink/accent — swap for your palette.
17
+
18
+ ---
19
+
20
+ ## 1. Kinetic headline (word-by-word rise)
21
+
22
+ Big title where each word springs up in sequence. GSAP timeline, fully seekable.
23
+
24
+ ```json
25
+ {
26
+ "asset": {
27
+ "type": "html5",
28
+ "html": "<div class=\"h\"><span>JUST</span> <span>DROPPED</span></div>",
29
+ "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}",
30
+ "js": "const tl=gsap.timeline();gsap.utils.toArray('.h span').forEach((el,i)=>{tl.to(el,{y:0,opacity:1,duration:0.6,ease:'back.out(1.7)'},i*0.18)});tl.to({},{duration:1.2});"
31
+ },
32
+ "start": 0,
33
+ "length": 3,
34
+ "width": 980,
35
+ "height": 420,
36
+ "offset": { "x": 0, "y": 0.02 }
37
+ }
38
+ ```
39
+
40
+ Each word is a `<span>` starting `translateY(120%)`; the trailing empty tween holds the title still before the clip ends. `Anton` falls back to `system-ui` unless you inline it (see Fonts above): `@font-face{font-family:'Anton';src:url('data:font/woff2;base64,…') format('woff2')}`.
41
+
42
+ ---
43
+
44
+ ## 2. Count-up number / price odometer
45
+
46
+ 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.
47
+
48
+ ```json
49
+ {
50
+ "asset": {
51
+ "type": "html5",
52
+ "html": "<div class=\"wrap\"><span class=\"cur\">$</span><span id=\"n\">0</span></div>",
53
+ "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}",
54
+ "js": "const o={v:0};const out=document.getElementById('n');const tl=gsap.timeline();tl.to(o,{v:395,duration:1.4,ease:'power2.out',onUpdate:()=>{out.textContent=Math.round(o.v)}});tl.to({},{duration:1.0});"
55
+ },
56
+ "start": 0,
57
+ "length": 3,
58
+ "width": 620,
59
+ "height": 220,
60
+ "offset": { "x": 0, "y": -0.1 }
61
+ }
62
+ ```
63
+
64
+ 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").
65
+
66
+ ---
67
+
68
+ ## 3. Shine / gloss sweep
69
+
70
+ A specular highlight that slides across text or a card — premium product gloss. Pure CSS keyframe (WAAPI-seekable). Put this clip **above** the thing it shines on, or wrap the content in the same clip.
71
+
72
+ ```json
73
+ {
74
+ "asset": {
75
+ "type": "html5",
76
+ "html": "<div class=\"plate\"><div class=\"label\">UTOPIA</div><div class=\"shine\"></div></div>",
77
+ "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 2.4s ease-in-out 0.4s 1 both}@keyframes sweep{to{transform:translateX(1100px) skewX(-12deg)}}",
78
+ "js": ""
79
+ },
80
+ "start": 0,
81
+ "length": 3,
82
+ "width": 860,
83
+ "height": 280,
84
+ "offset": { "x": 0, "y": 0 }
85
+ }
86
+ ```
87
+
88
+ `mix-blend-mode: overlay` makes the sweep read as a real highlight rather than a white bar. No JS needed.
89
+
90
+ ---
91
+
92
+ ## 4. Pulsing CTA button
93
+
94
+ A "SHOP NOW" pill with a soft breathing glow — draws the eye on an end card. CSS keyframes loop and seek cleanly.
95
+
96
+ ```json
97
+ {
98
+ "asset": {
99
+ "type": "html5",
100
+ "html": "<div class=\"cta\">SHOP NOW</div>",
101
+ "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:#5c3a21;color:#fff;font-size:46px;font-weight:700;letter-spacing:4px;text-transform:uppercase;box-shadow:0 0 0 0 rgba(92,58,33,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(92,58,33,0.45)}50%{transform:scale(1.04);box-shadow:0 0 36px 8px rgba(92,58,33,0.35)}}",
102
+ "js": ""
103
+ },
104
+ "start": 0,
105
+ "length": 4,
106
+ "width": 620,
107
+ "height": 170,
108
+ "offset": { "x": 0, "y": -0.4 }
109
+ }
110
+ ```
111
+
112
+ The clip is wider/taller than the pill so the glow has room (`overflow:hidden` would otherwise clip it). For a non-looping single pop, replace `infinite` with `1`.
113
+
114
+ ---
115
+
116
+ ## 5. Film-grain / texture overlay
117
+
118
+ A subtle moving grain over the whole frame — adds a moody, analogue feel (great over dark scenes). 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.
119
+
120
+ ```json
121
+ {
122
+ "asset": {
123
+ "type": "html5",
124
+ "html": "<div class=\"grain\"></div>",
125
+ "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)}}",
126
+ "js": ""
127
+ },
128
+ "start": 0,
129
+ "length": 10,
130
+ "width": 1080,
131
+ "height": 1920,
132
+ "offset": { "x": 0, "y": 0 }
133
+ }
134
+ ```
135
+
136
+ 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.
137
+
138
+ ---
139
+
140
+ ## Composing these
141
+
142
+ - 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.
143
+ - They don't overlap on a single track, so `shotstack validate <file>` stays clean. Run it before rendering.
144
+ - Reuse text via top-level `merge[]` (`{{title}}` in the HTML) — see the lower-third example in `html5.md`.
145
+ - 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.
@@ -0,0 +1,121 @@
1
+ # Ingesting local files (Ingest API)
2
+
3
+ The `shotstack ingest` commands host your own media on Shotstack so it can be
4
+ referenced from an Edit. This is the **Ingest API** — a separate service from
5
+ the Edit (render) API, living at `https://api.shotstack.io/ingest/{env}`. The
6
+ CLI targets it automatically; the same `SHOTSTACK_API_KEY` works for both.
7
+
8
+ Use it when your footage, audio, images, fonts, or subtitle files live on your
9
+ machine (or behind a URL) rather than already being on a public CDN. Sources
10
+ are stored until you delete them.
11
+
12
+ ## The one rule that matters
13
+
14
+ **An Edit can only reference media by URL.** A local path in an `asset.src`
15
+ will not render. So the flow is always:
16
+
17
+ ```
18
+ local file ──▶ shotstack ingest upload ──▶ hosted "source" URL ──▶ Edit asset.src ──▶ shotstack render
19
+ ```
20
+
21
+ `shotstack ingest upload <file>` performs the two-step Ingest dance for you in
22
+ one command: it asks the API for a pre-signed URL, then streams your file's
23
+ bytes straight to storage.
24
+
25
+ ## Get the hosted URL: use `--watch`
26
+
27
+ The bare command returns only a source **id** — the hosted URL does not exist
28
+ until ingestion finishes. Pass `--watch` to poll until the source is `ready`
29
+ and print its URL:
30
+
31
+ ```sh
32
+ shotstack ingest upload ./intro.mp4 --watch --output json
33
+ # → {"id":"zzytey4v-...","status":"importing"}
34
+ # → {"id":"zzytey4v-...","status":"ready","source":"https://shotstack-ingest-api-v1-sources.s3.ap-southeast-2.amazonaws.com/.../source.mp4","width":1920,"height":1080,"duration":12.4}
35
+ ```
36
+
37
+ The `source` field is the URL to drop into your Edit. In text mode the final
38
+ line is just `ready <url>`, easy to capture in a shell variable:
39
+
40
+ ```sh
41
+ SRC=$(shotstack ingest upload ./intro.mp4 --watch --output json | tail -n1 | jq -r .source)
42
+ ```
43
+
44
+ Without `--watch`, capture the id and poll it later:
45
+
46
+ ```sh
47
+ ID=$(shotstack ingest upload ./intro.mp4 --output json | jq -r .id)
48
+ shotstack ingest status "$ID" --watch --output json
49
+ ```
50
+
51
+ ## Worked example: upload then render
52
+
53
+ ```sh
54
+ # 1. Upload a local clip and a local logo, capturing the hosted URLs.
55
+ VIDEO=$(shotstack ingest upload ./clip.mp4 --watch --output json | tail -n1 | jq -r .source)
56
+ LOGO=$(shotstack ingest upload ./logo.png --watch --output json | tail -n1 | jq -r .source)
57
+
58
+ # 2. Reference them in an Edit (asset.src), then render.
59
+ cat > edit.json <<JSON
60
+ {
61
+ "timeline": {
62
+ "tracks": [
63
+ { "clips": [ { "asset": { "type": "image", "src": "$LOGO" }, "start": 0, "length": 5 } ] },
64
+ { "clips": [ { "asset": { "type": "video", "src": "$VIDEO" }, "start": 0, "length": 5 } ] }
65
+ ]
66
+ },
67
+ "output": { "format": "mp4", "size": { "width": 1920, "height": 1080 } }
68
+ }
69
+ JSON
70
+
71
+ shotstack render edit.json --watch
72
+
73
+ or
74
+
75
+ shotstack studio edit.json
76
+ ```
77
+
78
+ Remember `tracks[0]` is the **top** layer (see `shared/agent-core.md`), so the
79
+ logo track sits above the video.
80
+
81
+ ## Fetch a remote URL instead of uploading
82
+
83
+ If the media is already at a URL but you want Shotstack to host its own copy
84
+ (stable, no hot-linking), use `fetch`:
85
+
86
+ ```sh
87
+ shotstack ingest fetch https://example.com/big-video.mp4 --watch --output json
88
+ ```
89
+
90
+ This only works for fully validated urls. YouTube and similar video hosting
91
+ platforms are not supported.
92
+
93
+ ## Manage stored sources
94
+
95
+ ```sh
96
+ shotstack ingest list --output json # every source you've ingested
97
+ shotstack ingest status <id> --output json # one source's status + hosted URL
98
+ shotstack ingest delete <id> # remove a source from storage
99
+ ```
100
+
101
+ ## Status values
102
+
103
+ `queued` → `importing` → `ready` (terminal). A failed import ends in `failed`
104
+ (exit code 1 under `--watch`). `deleted` and `overwritten` are also terminal.
105
+
106
+ ## Supported files
107
+
108
+ Video, image, audio, font, and subtitle (`.srt`/`.vtt`) files. The CLI infers
109
+ a `Content-Type` from the file extension; uncommon extensions still upload
110
+ (the API sniffs the content) but naming files with a correct extension is
111
+ safest.
112
+
113
+ ## Environments
114
+
115
+ Sources are ingested into the environment you target (`--env stage` or `--env
116
+ v1`, default `v1`), mirroring `render`. Override the base URL for local testing
117
+ with `SHOTSTACK_INGEST_BASE_URL`.
118
+
119
+ ```sh
120
+ shotstack ingest upload ./clip.mp4 --watch --env stage
121
+ ```