You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: skills/shotstack/SKILL.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -158,7 +158,7 @@ This skill ships sub-references for the gnarly bits:
158
158
-[`references/svg.md`](references/svg.md) — required attrs, supported elements
159
159
-[`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.
Copy-paste `html5` clips that "pop" — kinetic type, value reveals, shine sweeps,
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
@@ -14,7 +14,8 @@ easings and durations. Read [`motion.md`](motion.md) for the why and the full re
14
14
15
15
Read [`html5.md`](html5.md) for the rules these obey. The non-negotiables:
16
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.
17
+
-**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.
18
19
-**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
20
-**The body is transparent by default** — the clip composites over the layers below (only set an opaque background if you want one).
20
21
-**No `<canvas>`** — Studio capture serialises the DOM; canvas bitmaps come through empty. Use SVG or positioned DOM.
@@ -79,19 +80,19 @@ Each word starts `translateY(120%)`, opacity 0; `0.6 s` rise on `power3.out`, wo
79
80
80
81
---
81
82
82
-
## 3. Count-up number / price odometer
83
+
## 3. Value reveal — bake and fade-in
83
84
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
+
**Category** data · **Use when** revealing a value, price, stat or metric · **Canvas** 620×220 · **Tags** number, price, stat, data · **Merge-friendly** target value
85
86
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
+
Bake the final value into the HTML and reveal it with opacity + blur + rise. The value is always present in the DOM — the animation controls only its visibility — so every captured frame shows the correct number. **Never use `onUpdate` to mutate `textContent`**: the seek harness doesn't fire `onUpdate` callbacks, so the value stays at its initial state (`$0`) in every frame.
@@ -101,7 +102,7 @@ Animate a value from 0 to its target. Seek-safe because the count lives in a **t
101
102
}
102
103
```
103
104
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
+
`slow` (0.8 s) reveal on `power3.out` (a decelerating settle reads right for a value), then a `hold`. Swap `395` for any value at generation time; for thousands separators format the string before baking it (`$63,642` not `63642`). Pair with a static label on an adjacent `rich-text` track ("FROM", "AUD").
105
106
106
107
---
107
108
@@ -185,7 +186,7 @@ A `data:` URI is fine **inside an html5 asset's CSS** (it's iframe content, not
185
186
186
187
## Brand kit — re-skin every snippet at once
187
188
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
+
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 `html`, `css`, and `js`— `merge` find/replace runs over all three strings:
189
190
190
191
```json
191
192
"merge": [
@@ -202,5 +203,5 @@ Then in any snippet's CSS, swap the literal hex for the token: `color:{{ink}}`,
202
203
- 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
204
- They don't overlap on a single track, so `shotstack validate <file>` stays clean. Run it before rendering.
204
205
- 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
+
- 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
207
- Heavier motion = longer render. Preview in `shotstack studio <file>` before spending credits.
|`css`| No | string | Stylesheet. Inlined into the iframe `<head>`. |
27
-
|`js`| No | string | Script. Runs after libraries are preloaded. |
26
+
|`css`| No | string | Stylesheet. Inlined into the iframe `<head>`. Supports merge fields. |
27
+
|`js`| No | string | Script. Runs after libraries are preloaded. Supports merge fields. |
28
28
29
29
Clip-level `width` and `height` set the iframe's pixel dimensions. They default to the edit's natural size.
30
30
@@ -56,7 +56,9 @@ These cover ~95% of motion-graphics use cases. **You can't load other libraries
56
56
57
57
## The browser harness (deterministic auto-seek)
58
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).
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 bake values into the HTML and animate their visibility (see the value-reveal snippet).
60
+
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.
60
62
61
63
**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
64
@@ -156,7 +158,7 @@ Slide-in name + role bar with subtle accent. **Clip sized to the bar (560×120),
156
158
-**Clip is the size of the bar, not the canvas** — placement is one `offset` change.
157
159
-**`html, body, .bar` all 560×120.** No absolute positioning inside the iframe — the bar IS the iframe content.
158
160
- 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.
161
+
- Merge fields (`{{name}}`, `{{role}}`) in the HTML, populated by **top-level**`merge[]` (sibling of `timeline`/`output`, NOT a clip property). Keeps the asset reusable. Merge fields also resolve in `css` and `js` — use `{{accent}}` in CSS for brand colours, or `{{targetValue}}` inside a JS string literal for data-driven animation targets.
160
162
- A trailing `.to({}, { duration: 3.5 })` holds the final state before the clip ends.
161
163
162
164
## Worked example: animated bar chart (D3 + GSAP)
@@ -236,6 +238,7 @@ The same pattern scales to scene transitions (each scene is a `<section>` with i
236
238
237
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.
238
240
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.
## Brand kit — set the palette once, re-skin everything
115
115
116
-
Don't hardcode hex and font per snippet. Declare the brand as top-level `merge[]` fields and reference the tokens in every clip's `css`/`html` — one edit re-skins the whole video — brand tokens applied once, via Shotstack merge:
116
+
Don't hardcode hex and font per snippet. Declare the brand as top-level `merge[]` fields and reference the tokens in every clip's `html`/`css`/`js` — one edit re-skins the whole video — brand tokens applied once, via Shotstack merge:
117
117
118
118
```json
119
119
"merge": [
@@ -129,7 +129,7 @@ Don't hardcode hex and font per snippet. Declare the brand as top-level `merge[]
129
129
.cta { background: {{accent}}; }
130
130
```
131
131
132
-
`merge` find/replace runs over the whole edit, including `html`/`css` strings, so `{{accent}}` resolves everywhere. Keep one accent and use it sparingly — a headline word, a number, a CTA, one glow — everything else neutral. **Colour is earned, never sprinkled.**
132
+
`merge` find/replace runs over the whole edit, including `html`/`css`/`js` strings, so `{{accent}}` resolves everywhere. Keep one accent and use it sparingly — a headline word, a number, a CTA, one glow — everything else neutral. **Colour is earned, never sprinkled.**
- "Invalid option: expected one of top|middle|bottom"
9
-
- Video looks stretched / squished after rendering
9
+
- Video looks stretched / squishing after rendering
10
10
- "Font not found"
11
11
- Captions cover the whole frame
12
12
- Clips flicker / "clips overlap" on one track
13
13
- Timeline renders but layers are wrong
14
14
- "Invalid asset URL"
15
+
- html5 clip is blank but render succeeded
15
16
- "Render failed" with no other detail
16
17
- Render takes much longer than expected
17
18
- Credits exhausted on stage environment
@@ -82,6 +83,18 @@ The `src` is not a public HTTPS URL, or it's a `data:` URI, or it's a local path
82
83
83
84
**Fix:** host the asset at a public HTTPS URL or use a presigned URL with credentials in the URL itself. For tests, pull from `references/asset-library.md`.
84
85
86
+
## html5 clip is blank but render succeeded
87
+
88
+
An `html5` clip renders as a completely blank frame, but the render reports `status: "done"` with no error. This is a **silent failure** — there is no feedback loop.
89
+
90
+
Two causes:
91
+
92
+
1.**JS syntax error in `asset.js`.** A missing semicolon, unbalanced brace, or any other parse error crashes the entire script before any animation runs. `shotstack validate <file>` catches this offline — it runs each `asset.js` string through a syntax check and reports the error with the clip path. **Always validate before rendering.**
93
+
94
+
2.**JS runtime error.** Referencing a DOM element that doesn't exist (`document.getElementById("missing")`), calling a method on `null`, or any other uncaught exception — the script stops mid-execution and the clip stays in its initial state (typically invisible if elements start at `opacity:0`). These are not caught by `validate` (they require the runtime DOM). To debug, wrap suspect code in `try/catch` and log to a visible element, or simplify the JS to isolate the failing line.
95
+
96
+
A third variant: values stuck at `$0` or their initial state. This happens when `onUpdate` callbacks are used to mutate `textContent` — the seek harness doesn't fire `onUpdate`, so the DOM never updates. Bake final values into the HTML and animate opacity/transform instead. See `references/html5.md` → "The browser harness".
97
+
85
98
## "Render failed" with no other detail
86
99
87
100
`shotstack status <id> --output json` returns the full response including the error message. The `--watch` text view truncates.
0 commit comments