From c42a19af1b8214a99724f2ad89555f597212eaf3 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 8 Jul 2026 18:56:37 +1000 Subject: [PATCH] feat: catch html5 JS syntax errors in validate and correct seek/merge docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 2 +- skills/shotstack/SKILL.md | 2 +- skills/shotstack/references/html5-snippets.md | 23 +++++---- skills/shotstack/references/html5.md | 11 ++-- skills/shotstack/references/motion.md | 4 +- .../shotstack/references/troubleshooting.md | 15 +++++- skills/shotstack/shared/agent-core.md | 51 +++++++++++++++++-- src/commands/validate.ts | 2 +- src/lib/validate.ts | 22 ++++++++ tests/validate.test.ts | 38 ++++++++++++++ 10 files changed, 146 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 0337952..da52910 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skills/shotstack/SKILL.md b/skills/shotstack/SKILL.md index a51911f..ceceb15 100644 --- a/skills/shotstack/SKILL.md +++ b/skills/shotstack/SKILL.md @@ -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 diff --git a/skills/shotstack/references/html5-snippets.md b/skills/shotstack/references/html5-snippets.md index f02186e..dcc4ee5 100644 --- a/skills/shotstack/references/html5-snippets.md +++ b/skills/shotstack/references/html5-snippets.md @@ -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`. @@ -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 ``** — 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 --- -## 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": "
$0
", - "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": "
$395
", + "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, @@ -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"). --- @@ -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": [ @@ -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 ` 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 ` before spending credits. diff --git a/skills/shotstack/references/html5.md b/skills/shotstack/references/html5.md index 7a13da9..077fd99 100644 --- a/skills/shotstack/references/html5.md +++ b/skills/shotstack/references/html5.md @@ -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 ``. | -| `js` | No | string | Script. Runs after libraries are preloaded. | +| `css` | No | string | Stylesheet. Inlined into the iframe ``. 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. @@ -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`. @@ -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) @@ -236,6 +238,7 @@ The same pattern scales to scene transitions (each scene is a `
` with i If you're building something that genuinely cannot be expressed without canvas, render it as a `