Skip to content

Commit c42a19a

Browse files
committed
feat: catch html5 JS syntax errors in validate and correct seek/merge docs
Validate now runs each html5 asset.js string through new Function() to catch syntax errors offline — a JS error renders a completely blank clip with no render error, so this is the only feedback loop before spending credits. Skill docs corrected with findings from real renders: - onUpdate callbacks do not fire under seek. The harness seeks the GSAP timeline to each frame without playing through, so DOM mutations in onUpdate (textContent, innerHTML) never appear. Replaced the count-up snippet with a bake-and-reveal pattern: final values in HTML, animate opacity/transform only. - Merge fields resolve in html5 css and js, not just html. Added an Edit-wide merge fields section to agent-core.md covering rules (UPPER_SNAKE_CASE, case sensitive, whitespace ignored), where they resolve (every string in the Edit), when to use merge vs baking at generation time, and the ?data=true&merged=true status check. - Documented silent blank-frame failure in troubleshooting and html5 common mistakes: JS errors produce blank clips with status done.
1 parent 768116b commit c42a19a

10 files changed

Lines changed: 146 additions & 24 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@shotstack/cli",
3-
"version": "0.6.0",
3+
"version": "0.7.0",
44
"description": "Command-line interface for the Shotstack video rendering API.",
55
"license": "Apache-2.0",
66
"homepage": "https://github.com/shotstack/shotstack-cli",

‎skills/shotstack/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ This skill ships sub-references for the gnarly bits:
158158
- [`references/svg.md`](references/svg.md) — required attrs, supported elements
159159
- [`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.
160160
- [`references/html5.md`](references/html5.md) — HTML5 asset: fields, preloaded libs (gsap/d3/anime/lottie), browser harness, sizing, worked examples
161-
- [`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
161+
- [`references/html5-snippets.md`](references/html5-snippets.md) — copy-paste motion-graphic clips: kinetic headline, value reveal, shine sweep, pulsing CTA, film grain
162162
- [`references/fonts.md`](references/fonts.md) — built-in fonts, Google Fonts URL pattern, custom-font workflow
163163
- [`references/asset-library.md`](references/asset-library.md) — placeholder videos, images, music
164164
- [`references/troubleshooting.md`](references/troubleshooting.md) — common errors and fixes

‎skills/shotstack/references/html5-snippets.md‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# HTML5 snippet pack — drop-in motion graphics
22

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

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

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

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.
1819
- **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`/`%`.
1920
- **The body is transparent by default** — the clip composites over the layers below (only set an opaque background if you want one).
2021
- **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
7980

8081
---
8182

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

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
8586

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

8889
```json
8990
{
9091
"asset": {
9192
"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});"
93+
"html": "<div class=\"wrap\"><span class=\"cur\">$</span><span class=\"n\">395</span></div>",
94+
"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}",
95+
"js": "gsap.to('.wrap',{opacity:1,y:0,filter:'blur(0px)',duration:0.8,ease:'power3.out'});gsap.to({},{duration:1.5});"
9596
},
9697
"start": 0,
9798
"length": 3,
@@ -101,7 +102,7 @@ Animate a value from 0 to its target. Seek-safe because the count lives in a **t
101102
}
102103
```
103104

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

106107
---
107108

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

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

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

190191
```json
191192
"merge": [
@@ -202,5 +203,5 @@ Then in any snippet's CSS, swap the literal hex for the token: `color:{{ink}}`,
202203
- 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.
203204
- They don't overlap on a single track, so `shotstack validate <file>` stays clean. Run it before rendering.
204205
- 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.
206207
- Heavier motion = longer render. Preview in `shotstack studio <file>` before spending credits.

‎skills/shotstack/references/html5.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ This is the **modern replacement for the deprecated `html` asset.** `html5` runs
2323
|---|---|---|---|
2424
| `type` | Yes | `"html5"` | Discriminator. |
2525
| `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. |
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. |
2828

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

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

5757
## The browser harness (deterministic auto-seek)
5858

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

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

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

162164
## 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
236238

237239
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.
238240
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.
239242

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

‎skills/shotstack/references/motion.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ tl.from('.el', { opacity: 0, y: 12, duration: 0.6, ease: 'power3.out' }) // in
113113

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

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

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

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

134134
## Motion checklist
135135

‎skills/shotstack/references/troubleshooting.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ Common errors and the fix for each. **Catch most of them before rendering:** `sh
66

77
- "Unknown property: alignment" / wrong property names
88
- "Invalid option: expected one of top|middle|bottom"
9-
- Video looks stretched / squished after rendering
9+
- Video looks stretched / squishing after rendering
1010
- "Font not found"
1111
- Captions cover the whole frame
1212
- Clips flicker / "clips overlap" on one track
1313
- Timeline renders but layers are wrong
1414
- "Invalid asset URL"
15+
- html5 clip is blank but render succeeded
1516
- "Render failed" with no other detail
1617
- Render takes much longer than expected
1718
- 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
8283

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

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+
8598
## "Render failed" with no other detail
8699

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

0 commit comments

Comments
 (0)