Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@shotstack/cli",
"version": "0.6.0",
"version": "0.7.0",
"description": "Command-line interface for the Shotstack video rendering API.",
"license": "Apache-2.0",
"homepage": "https://github.com/shotstack/shotstack-cli",
Expand Down
2 changes: 1 addition & 1 deletion skills/shotstack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ This skill ships sub-references for the gnarly bits:
- [`references/svg.md`](references/svg.md) — required attrs, supported elements
- [`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.
- [`references/html5.md`](references/html5.md) — HTML5 asset: fields, preloaded libs (gsap/d3/anime/lottie), browser harness, sizing, worked examples
- [`references/html5-snippets.md`](references/html5-snippets.md) — copy-paste motion-graphic clips: kinetic headline, count-up/price odometer, shine sweep, pulsing CTA, film grain
- [`references/html5-snippets.md`](references/html5-snippets.md) — copy-paste motion-graphic clips: kinetic headline, value reveal, shine sweep, pulsing CTA, film grain
- [`references/fonts.md`](references/fonts.md) — built-in fonts, Google Fonts URL pattern, custom-font workflow
- [`references/asset-library.md`](references/asset-library.md) — placeholder videos, images, music
- [`references/troubleshooting.md`](references/troubleshooting.md) — common errors and fixes
Expand Down
23 changes: 12 additions & 11 deletions skills/shotstack/references/html5-snippets.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# HTML5 snippet pack — drop-in motion graphics

Copy-paste `html5` clips that "pop" — kinetic type, count-ups, shine sweeps,
Copy-paste `html5` clips that "pop" — kinetic type, value reveals, shine sweeps,
pulsing CTAs, grain. Each is a **single clip**: paste it into a track's
`clips[]`, set `start`/`length`, and position with `offset`.

Expand All @@ -14,7 +14,8 @@ easings and durations. Read [`motion.md`](motion.md) for the why and the full re

Read [`html5.md`](html5.md) for the rules these obey. The non-negotiables:

- **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.
- **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.
- **`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.
- **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`/`%`.
- **The body is transparent by default** — the clip composites over the layers below (only set an opaque background if you want one).
- **No `<canvas>`** — Studio capture serialises the DOM; canvas bitmaps come through empty. Use SVG or positioned DOM.
Expand Down Expand Up @@ -79,19 +80,19 @@ Each word starts `translateY(120%)`, opacity 0; `0.6 s` rise on `power3.out`, wo

---

## 3. Count-up number / price odometer
## 3. Value reveal — bake and fade-in

**Category** data · **Use when** revealing a value, price, stat or metric · **Canvas** 620×220 · **Tags** number, count, price, data · **Merge-friendly** target value
**Category** data · **Use when** revealing a value, price, stat or metric · **Canvas** 620×220 · **Tags** number, price, stat, data · **Merge-friendly** target value

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.
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.

```json
{
"asset": {
"type": "html5",
"html": "<div class=\"wrap\"><span class=\"cur\">$</span><span id=\"n\">0</span></div>",
"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}",
"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});"
"html": "<div class=\"wrap\"><span class=\"cur\">$</span><span class=\"n\">395</span></div>",
"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;opacity:0;transform:translateY(16px);filter:blur(10px)}.cur{font-size:70px;margin-right:6px}.n{font-size:150px;letter-spacing:-2px}",
"js": "gsap.to('.wrap',{opacity:1,y:0,filter:'blur(0px)',duration:0.8,ease:'power3.out'});gsap.to({},{duration:1.5});"
},
"start": 0,
"length": 3,
Expand All @@ -101,7 +102,7 @@ Animate a value from 0 to its target. Seek-safe because the count lives in a **t
}
```

`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").
`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").

---

Expand Down Expand Up @@ -185,7 +186,7 @@ A `data:` URI is fine **inside an html5 asset's CSS** (it's iframe content, not

## Brand kit — re-skin every snippet at once

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:
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:

```json
"merge": [
Expand All @@ -202,5 +203,5 @@ Then in any snippet's CSS, swap the literal hex for the token: `color:{{ink}}`,
- 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.
- They don't overlap on a single track, so `shotstack validate <file>` stays clean. Run it before rendering.
- Reuse text via top-level `merge[]` (`{{title}}` in the HTML) — see the lower-third example in `html5.md`.
- 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.
- 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.
- Heavier motion = longer render. Preview in `shotstack studio <file>` before spending credits.
11 changes: 7 additions & 4 deletions skills/shotstack/references/html5.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ This is the **modern replacement for the deprecated `html` asset.** `html5` runs
|---|---|---|---|
| `type` | Yes | `"html5"` | Discriminator. |
| `html` | Yes | string | Body markup. Supports merge fields (`{{title}}`). |
| `css` | No | string | Stylesheet. Inlined into the iframe `<head>`. |
| `js` | No | string | Script. Runs after libraries are preloaded. |
| `css` | No | string | Stylesheet. Inlined into the iframe `<head>`. Supports merge fields. |
| `js` | No | string | Script. Runs after libraries are preloaded. Supports merge fields. |

Clip-level `width` and `height` set the iframe's pixel dimensions. They default to the edit's natural size.

Expand Down Expand Up @@ -56,7 +56,9 @@ These cover ~95% of motion-graphics use cases. **You can't load other libraries

## The browser harness (deterministic auto-seek)

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).
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).

**`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.

**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`.

Expand Down Expand Up @@ -156,7 +158,7 @@ Slide-in name + role bar with subtle accent. **Clip sized to the bar (560×120),
- **Clip is the size of the bar, not the canvas** — placement is one `offset` change.
- **`html, body, .bar` all 560×120.** No absolute positioning inside the iframe — the bar IS the iframe content.
- One GSAP timeline drives every animation.
- 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 (`{{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.
- A trailing `.to({}, { duration: 3.5 })` holds the final state before the clip ends.

## Worked example: animated bar chart (D3 + GSAP)
Expand Down Expand Up @@ -236,6 +238,7 @@ The same pattern scales to scene transitions (each scene is a `<section>` with i

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.
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.
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.

## When to use `html5` vs `rich-text`/`svg`

Expand Down
4 changes: 2 additions & 2 deletions skills/shotstack/references/motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ tl.from('.el', { opacity: 0, y: 12, duration: 0.6, ease: 'power3.out' }) // in

## Brand kit — set the palette once, re-skin everything

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:
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:

```json
"merge": [
Expand All @@ -129,7 +129,7 @@ Don't hardcode hex and font per snippet. Declare the brand as top-level `merge[]
.cta { background: {{accent}}; }
```

`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.**
`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.**

## Motion checklist

Expand Down
15 changes: 14 additions & 1 deletion skills/shotstack/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ Common errors and the fix for each. **Catch most of them before rendering:** `sh

- "Unknown property: alignment" / wrong property names
- "Invalid option: expected one of top|middle|bottom"
- Video looks stretched / squished after rendering
- Video looks stretched / squishing after rendering
- "Font not found"
- Captions cover the whole frame
- Clips flicker / "clips overlap" on one track
- Timeline renders but layers are wrong
- "Invalid asset URL"
- html5 clip is blank but render succeeded
- "Render failed" with no other detail
- Render takes much longer than expected
- Credits exhausted on stage environment
Expand Down Expand Up @@ -82,6 +83,18 @@ The `src` is not a public HTTPS URL, or it's a `data:` URI, or it's a local path

**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`.

## html5 clip is blank but render succeeded

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.

Two causes:

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.**

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.

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".

## "Render failed" with no other detail

`shotstack status <id> --output json` returns the full response including the error message. The `--watch` text view truncates.
Expand Down
Loading