Merge pull request #1026 from heygen-com/fix/w2h-skill-audit

fix(skill): w2h audit + enforcement — close 19 shirking patterns from two agent debriefs
This commit is contained in:
Ular Kimsanov
2026-05-22 14:20:01 -07:00
committed by GitHub
10 changed files with 1120 additions and 59 deletions
+11 -2
View File
@@ -14,7 +14,16 @@ Users say things like:
- "Turn this website into a 15-second social ad for Instagram" - "Turn this website into a 15-second social ad for Instagram"
- "Create a 30-second product tour from https://..." - "Create a 30-second product tour from https://..."
The workflow has 7 steps. Each produces an artifact that gates the next. By default it's collaborative — gates marked 💬 stop and ask the user. If the user signals autonomous mode ("decide for me", "surprise me"), every 💬 gate is skipped; see step-2-brief.md for how that propagates. The workflow has 7 steps. Each produces an artifact that gates the next. By default it's collaborative — gates marked 💬 stop and ask the user. If the user signals autonomous mode ("decide for me", "surprise me"), 💬 user-preference gates are skipped; see step-2-brief.md for how that propagates.
**Autonomous mode is NOT "skip all gates."** Auto mode covers user-preference questions (TTS provider, voice, color emphasis, beat count, music yes/no, captions yes/no — where the agent decides on the user's behalf). It does NOT cover quality-verification gates. The following remain non-skippable in auto mode:
- Asset Audit (Step 3) — viewing contact sheets and justifying USE/SKIP for each asset
- Per-beat HTML read (Step 5) — structured evidence block per beat
- DoD checklist (Step 6) — including animation-map, per-warning WCAG verification, audio/motion playback
- Honest disclosure section (Step 6) — "What I did NOT verify" must appear in your final summary
If you find yourself reasoning "auto mode says bias toward action, so I'll skip X" — and X is a verification gate, not a preference question — that reasoning is wrong. Bias toward action applies to deciding _what to build_, not to deciding _whether to verify_.
--- ---
@@ -120,7 +129,7 @@ Beat count is not in this table intentionally — it should come from the storyb
| File | When to read | | File | When to read |
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| [step-0-capture.md](references/step-0-capture.md) | Step 0 — capture, understand the brand and product, write strategy-first site summary | | [step-0-capture.md](references/step-0-capture.md) | Step 0 — capture, understand the brand and product, write strategy-first site summary |
| [step-1-design.md](references/step-1-design.md) | Step 1 — write DESIGN.md brand cheat sheet (6 sections, 250-350 lines) | | [step-1-design.md](references/step-1-design.md) | Step 1 — write DESIGN.md brand cheat sheet (5 sections, 250-350 lines; 50-line fast-path for billboard-style social ads) |
| [step-2-brief.md](references/step-2-brief.md) | Step 2 — align on message, narrative arc, audience with user | | [step-2-brief.md](references/step-2-brief.md) | Step 2 — align on message, narrative arc, audience with user |
| [capabilities.md](references/capabilities.md) | Steps 2 & 5 — full inventory of what HyperFrames can do (24 sections). Scan the TOC during the brief, deep-dive specific sections during build | | [capabilities.md](references/capabilities.md) | Steps 2 & 5 — full inventory of what HyperFrames can do (24 sections). Scan the TOC during the brief, deep-dive specific sections during build |
| [step-3-storyboard.md](references/step-3-storyboard.md) | Step 3 — storyboard + script (combined) with user review gate | | [step-3-storyboard.md](references/step-3-storyboard.md) | Step 3 — storyboard + script (combined) with user review gate |
@@ -115,6 +115,70 @@ After lint passes, snapshots are taken, and you've fixed every issue you saw —
So in your report, name the hex codes you used, the captured asset paths you placed, the headline `font-size`, and the GSAP timeline's last `tl.fromTo(...)` timestamp. Brief, concrete, true. If anything diverges from DESIGN.md or the storyboard, say so explicitly — the main agent can decide whether to accept the divergence or send you back to fix it. Surprises caught at this hand-off cost minutes; surprises caught at Step 6 cost iterations. So in your report, name the hex codes you used, the captured asset paths you placed, the headline `font-size`, and the GSAP timeline's last `tl.fromTo(...)` timestamp. Brief, concrete, true. If anything diverges from DESIGN.md or the storyboard, say so explicitly — the main agent can decide whether to accept the divergence or send you back to fix it. Surprises caught at this hand-off cost minutes; surprises caught at Step 6 cost iterations.
### FLAG protocol — required phrasing for non-blocking issues
When you find any of these, surface them as **FLAGS** in your report, not as conditional suggestions:
- Visual states that briefly look broken (empty containers, hanging elements, gap moments)
- Spec ambiguities you had to resolve by guessing
- Linter bugs you worked around
- Tween values you changed from the spec because they wouldn't fit
**Forbidden phrasing:** "if the X feels too long, you could...", "consider tweaking Y", "might want to..."
**Required phrasing — concrete, actionable, with line numbers:**
```
FLAG: at beat-local t=1.2s the doc card is visible but its inner content is still
opacity 0 — a 0.4s empty-panel window.
RECOMMENDED FIX: pull title typewriter from 1.6s → 1.4s
in compositions/beat-5-name.html line 234.
```
The main agent MUST EITHER apply each FLAG's fix OR write a one-sentence rejection with reason. Silently dropping a FLAG is a verification failure that gets caught at Step 6 (or worse, in the user's preview).
### Spec ambiguity — escalate, don't paper over
If STORYBOARD.md gives you a transition or transformation but doesn't establish the **start** state, do NOT guess. Examples of ambiguity worth flagging:
- "Row 1 transitions from Huly Blue to Huly Orange at 3.5s" — but Row 1's initial color isn't specified
- "Headline grows" — but the start size isn't specified
- "Cards slide in" — but the off-screen position isn't specified
- "Subhead appears after the headline" — but exact timing offset isn't specified
**Required action:** FLAG the ambiguity in your report verbatim:
```
FLAG: STORYBOARD.md beat 3 says "Row 1 transitions blue → orange at 3.5s" but
Row 1's initial color is not specified anywhere. I interpreted Row 1 starts
blue and tweened to orange. CONFIRM or correct.
```
The main agent then confirms or corrects before Step 6 advances. Picking an interpretation silently means the build looks "fine" while diverging from intent — and the user only notices in motion.
### Sub-agent diagnoses are unverified claims, not facts
When a sub-agent reports "this is a linter false positive" / "this is a known bug" / "this attribute doesn't work as documented" — those are HYPOTHESES, not findings. Sub-agents diagnose from one symptom; they don't have repo-wide context.
Before propagating any sub-agent diagnosis (e.g., applying the same "workaround" to another beat, or telling the user "this is a known bug"), do ONE of:
1. **Verify by reading the source.** Open the file the sub-agent claims is buggy. Confirm the bug exists. Example: "I read `packages/core/src/lint/utils.ts:42` and confirmed the regex matches `url(\"data:image/svg+xml...\")` incorrectly. The workaround is to base64-encode the URI."
2. **Disclose the unverified claim.** Don't suppress it — surface it. Example: "Sub-agent for beat 2 diagnosed `root_missing_composition_id` as a linter false positive on inline SVG data URIs. I applied the same workaround to beat 4 WITHOUT verifying the underlying claim. Worth filing as a regression against `packages/core/src/lint/utils.ts` to confirm."
**Forbidden:** silently adopting the workaround pattern and presenting "lint passes" as evidence. If the workaround came from an unverified diagnosis, "lint passes because the diagnosis was correct AND I worked around it" and "lint passes because the diagnosis was wrong but the workaround happened to make the symptom disappear" are both possible. Without verification, you don't know which. The next session inherits the workaround AND the unverified diagnosis.
### When you accept a sub-agent's divergence from spec — UPDATE the spec
If a sub-agent reports "I diverged from STORYBOARD.md because..." AND you accept the divergence, you MUST update STORYBOARD.md to reflect the actual implementation. Otherwise the spec lies about the artifact.
Examples:
- Sub-agent: "Storyboard says 'HULY' uppercase but the actual logo asset is lowercase 'huly'. I used lowercase." Accept → edit STORYBOARD.md beat N to say "huly" lowercase. Note the change inline.
- Sub-agent: "Storyboard says cells at 56px but they read too small at 1920×1080. I used 96px." Accept → edit STORYBOARD.md beat N's cell size to 96px.
- Sub-agent: "Storyboard says SFX at t=4.7s but the visual moment lands at t=5.2s; I aligned SFX to the visual." Accept → edit STORYBOARD.md SFX line to t=5.2s.
**Forbidden:** accepting the divergence silently and leaving the storyboard with the wrong spec. The next session reading STORYBOARD.md will trust it as ground truth. The spec is a contract; if you break the contract, update the contract.
--- ---
## Continuous motion — the most important rule ## Continuous motion — the most important rule
@@ -175,7 +239,7 @@ If any are missing from the beat spec, the beat is under-defined. Don't fill the
- CSS CENTERING: no `transform: translate(-50%, -50%)` with GSAP transforms. Use flexbox or `xPercent/yPercent`. - CSS CENTERING: no `transform: translate(-50%, -50%)` with GSAP transforms. Use flexbox or `xPercent/yPercent`.
- QUERYSELECTOR: `document.getElementById("id")` with null guards. No method calls without null check. - QUERYSELECTOR: `document.getElementById("id")` with null guards. No method calls without null check.
- CHARACTER SPANS: `display:inline-block` on spaces collapses them. Use ` ` or per-word spans. - CHARACTER SPANS: `display:inline-block` on spaces collapses them. Use ` ` or per-word spans.
- COUNTERS: no `onUpdate` callbacks. Discrete `tl.set(el, {textContent: "42"}, 2.5)` at timestamps. - COUNTERS: no `onUpdate` for numeric counters — use discrete `tl.set(el, {textContent: "42"}, 2.5)` at timestamps. `onUpdate` and `tl.call()` ARE supported for canvas/WebGL rendering loops and character-by-character typing — see capabilities.md §10.
- TIMELINE: `window.__timelines["beat-N-name"] = tl` synchronously. Key = `data-composition-id`. - TIMELINE: `window.__timelines["beat-N-name"] = tl` synchronously. Key = `data-composition-id`.
- DETERMINISTIC: no `Math.random()`, `Date.now()`, `requestAnimationFrame`, `repeat:-1`. - DETERMINISTIC: no `Math.random()`, `Date.now()`, `requestAnimationFrame`, `repeat:-1`.
- Always `tl.fromTo()` not `tl.from()` for entrances. - Always `tl.fromTo()` not `tl.from()` for entrances.
@@ -576,7 +576,7 @@ Full editor in packages/studio/:
- No `setTimeout` / `setInterval` in timeline construction - No `setTimeout` / `setInterval` in timeline construction
- No `requestAnimationFrame` (timeline-driven; engine seeks per frame) - No `requestAnimationFrame` (timeline-driven; engine seeks per frame)
- No `repeat: -1` (calculate exact repeats: `Math.ceil(duration / cycleDuration) - 1`) - No `repeat: -1` (calculate exact repeats: `Math.ceil(duration / cycleDuration) - 1`)
- No `tl.call(fn)` / `tl.add(function)` / `onComplete`/`onStart`/`onUpdate`/`onRepeat` callbacks (engine doesn't fire them) - No `onComplete`/`onStart`/`onRepeat` callbacks (engine doesn't fire them). **Exception:** `onUpdate` and `tl.call()` ARE supported — they're required for canvas/WebGL rendering, character-by-character typing, and counter patterns. See §10 (Canvas 2D procedural art) for the documented pattern.
- No `gsap.set` on clips from later scenes (use `tl.set(selector, vars, position)`) - No `gsap.set` on clips from later scenes (use `tl.set(selector, vars, position)`)
- Synchronous timeline construction (no async) - Synchronous timeline construction (no async)
- Master clock can clamp at composition end - Master clock can clamp at composition end
@@ -33,7 +33,7 @@ Sub-agents try to use the fonts you list. The manifest tells you exactly what's
--- ---
## The 6 sections to write ## The 5 sections to write
### `## 1. Visual Theme (one paragraph)` ### `## 1. Visual Theme (one paragraph)`
@@ -256,32 +256,7 @@ Generous whitespace as confidence. Section gaps are always `60100px`. Content
--- ---
### `## 5. Depth & Elevation` ### `## 5. Iteration Guide` (the load-bearing section)
Document the brand's shadow philosophy and the actual shadow values used.
#### Shadow table
```markdown
| Level | Value | Used for |
| ------------ | -------------------------------- | ----------------------------------------------- |
| Flat (0) | none | Default — most surfaces, inputs, body text |
| Raised (1) | `0 2px 8px rgba(0, 0, 0, 0.1)` | Cards on white, hovered surfaces, floating CTAs |
| Elevated (2) | `0 4px 12px rgba(0, 0, 0, 0.15)` | Dropdown menus, popovers |
| Floating (3) | `0 8px 24px rgba(0, 0, 0, 0.2)` | Modals, important overlays |
```
#### Shadow philosophy (one paragraph)
How does this brand use shadows? Sparingly with soft falloff, or dramatically with hard edges? Are they present by default or only on hover? On dark backgrounds, do shadows invert to glow effects?
```markdown
Shadows are minimal — used only to signal interactivity (hover) or layering (modals). All shadows are soft-edged (blur ≥ 8px). On dark backgrounds, shadows convert to subtle glow (`0 0 24px rgba(255, 99, 99, 0.15)` using the brand's accent color). Hard shadows do not exist in this system.
```
---
### `## 6. Iteration Guide` (the load-bearing section)
510 numbered rules that encode the most important brand decisions. Each rule is a **single actionable sentence stating what to do, with the specific values from this site.** These are the "if in doubt, do this" rules sub-agents consult while composing beats. 510 numbered rules that encode the most important brand decisions. Each rule is a **single actionable sentence stating what to do, with the specific values from this site.** These are the "if in doubt, do this" rules sub-agents consult while composing beats.
@@ -118,7 +118,16 @@ When the user gives no creative direction, default to what the brand's visual id
With that minimum in hand, still write an ambitious storyboard. "Surprise me" means "impress me," not "play it safe." Go bold. With that minimum in hand, still write an ambitious storyboard. "Surprise me" means "impress me," not "play it safe." Go bold.
**Autonomous mode propagates.** When the user signals "surprise me" / "decide for me" / "just build it" here at Step 2, that signal kills every downstream 💬 gate too — Step 3's storyboard approval, Step 4's TTS provider choice, captions. Make all creative decisions yourself and present the finished video at the end. Do not ask four separate questions across four separate steps. Read the room once and commit. **Autonomous mode propagates — for user-preference gates only.** When the user signals "surprise me" / "decide for me" / "just build it" here at Step 2, that signal kills downstream user-preference 💬 gates: Step 3's storyboard approval, Step 4's TTS provider choice, music yes/no, captions yes/no. Make those creative decisions yourself and present the finished video at the end. Do not ask four separate questions across four separate steps. Read the room once and commit.
**Auto mode does NOT skip quality-verification gates.** These run regardless and must produce evidence in your final summary:
- Asset Audit (Step 3) — view contact sheets, justify USE/SKIP per asset
- Per-beat HTML evidence block (Step 5)
- DoD checklist (Step 6) — animation-map, per-warning WCAG verification, audio + motion playback (or explicit "deferred" disclosure)
- "What I did NOT verify" disclosure (Step 6)
**Test for "preference vs quality gate":** if the answer changes the _content_ of the video (which voice? captions on? beat 3 cinematic or fast?), it's a preference — auto mode decides. If the answer is "did the verification happen?", it's a quality gate — auto mode does NOT apply. Reasoning "auto mode says bias toward action, so I'll skip the contact sheets" misuses auto mode.
### Specific direction ### Specific direction
@@ -139,6 +139,44 @@ There might be VFX blocks available (vfx-liquid-glass, vfx-iphone-device, vfx-sh
**Shader transitions — block name ≠ shader name.** When you run the commands above and see `domain-warp-dissolve` in `registry/blocks/`, the HyperShader runtime name is `domain-warp` (without "-dissolve"). After installing a block, open its showcase HTML (`compositions/<block-name>.html`) and find the actual shader name used in `HyperShader.init()`. That is what you put in the storyboard. Then delete the showcase file — it's a demo only and will pollute your compositions/ directory with lint warnings. **Shader transitions — block name ≠ shader name.** When you run the commands above and see `domain-warp-dissolve` in `registry/blocks/`, the HyperShader runtime name is `domain-warp` (without "-dissolve"). After installing a block, open its showcase HTML (`compositions/<block-name>.html`) and find the actual shader name used in `HyperShader.init()`. That is what you put in the storyboard. Then delete the showcase file — it's a demo only and will pollute your compositions/ directory with lint warnings.
## Asset Audit — REQUIRED before writing beats (non-skippable)
The skill's #1 purpose is to USE the brand's captured assets — not rebuild them from CSS. Most of your beats should feature at least one captured asset: a hero illustration, a signature SVG, product photography, brand mark, distinctive graphic. **If your STORYBOARD.md ends with only the logo used, you have failed this step.**
**Why this gate exists:** Earlier sessions wrote their own "Asset Audit" that said SKIP for 60+ of 65 captured assets, used only the logo, and shipped a video that visually was indistinguishable from a generic dark-mode SaaS launch. The captured MetaBrain illustration, the GitHub-sync diagram, the knowledge-base hero — all left on the floor. The signature visuals that make a brand recognizable were absent. Don't repeat that.
**Required pre-storyboard procedure:**
1. **View every page of `capture/assets/contact-sheet-*.jpg`** AND every page of `capture/assets/svgs/contact-sheet-*.jpg`. These are the sheets generated by capture for this exact purpose. Open each page; scan cell-by-cell. Do not skim — you are looking for the brand's visual identity, frame by frame.
2. **For each contact sheet page, paste this block into STORYBOARD.md under an "Asset Audit" section:**
```
Contact sheet: capture/assets/contact-sheet-1.jpg (page 1 of N)
5 most visually distinctive assets I see (filename + one-sentence description of what the image shows):
1. <filename>: <what's actually pictured — not the filename, the content>
2. <filename>: <description>
3. <filename>: <description>
4. <filename>: <description>
5. <filename>: <description>
```
Repeat for every contact sheet page. The number of pages × 5 is your candidate asset pool.
3. **For each beat in STORYBOARD.md**, choose USE or SKIP for each candidate asset:
- **USE** means the asset appears in the beat's HTML at build time (`<img src=...>`, inline SVG, `background-image: url(...)`).
- **SKIP** requires a one-sentence reason explaining why this asset doesn't serve this beat. "Doesn't fit storyboard" is not a reason — name which storyboard moment failed to find a use for it.
4. **Brand-defaults floor:** at least ONE beat must use the brand's signature visual (hero illustration, hero photograph, or signature diagram — not the logo). If you've named 5+ candidate hero illustrations in step 2 above and zero of them appear in any beat, that is the failure mode this gate exists to catch.
**Forbidden:**
- Writing "SKIP" for every asset except the logo without per-asset justification
- Reading `capture/extracted/asset-descriptions.md` (the text file) and making decisions from filenames alone, without opening the contact sheets
- Concluding "I'll rebuild the GitHub-sync diagram in CSS" when the brand's own SVG of that diagram is sitting in `capture/assets/`. Use the real asset.
If your final beat list uses less than ~30% of relevant captured assets (relevant = anything except the favicon and tiny UI icons), revisit. The brand is visually carried by its own art; rebuilding it from divs erases what makes it recognizable.
### HTML-in-Canvas — plan for it here, build in Step 5 ### HTML-in-Canvas — plan for it here, build in Step 5
The `drawElementImage` Chrome API captures any live HTML/CSS as a GPU-accelerated texture at 60fps. This is HyperFrames' highest-impact capability — it lets you render captured product screenshots or UI through: The `drawElementImage` Chrome API captures any live HTML/CSS as a GPU-accelerated texture at 60fps. This is HyperFrames' highest-impact capability — it lets you render captured product screenshots or UI through:
@@ -151,7 +189,13 @@ When planning beats, decide which ones deserve an HTML-in-Canvas treatment vs. a
### SFX assignment — happens here, not in Step 5 ### SFX assignment — happens here, not in Step 5
**Before writing beats,** read `skills/website-to-hyperframes/assets/sfx/manifest.json` (or your local copy at `sfx/manifest.json` if already copied to the project). Each entry has a filename, duration in seconds, and description. Assign **specific SFX files** to exact moments in the storyboard. Step 5 implements what you specify here — it makes no SFX decisions. **Before writing beats,** read the SFX manifest. Locate it from your current directory:
```bash
find "$HOME" -path '*/website-to-hyperframes/assets/sfx/manifest.json' -maxdepth 10 2>/dev/null | head -1
```
Or if you already copied SFX into the project (Step 5 does this), read your local `sfx/manifest.json`. Each entry has a filename, duration in seconds, and description. Assign **specific SFX files** to exact moments in the storyboard. Step 5 implements what you specify here — it makes no SFX decisions.
Per beat, specify SFX like: Per beat, specify SFX like:
@@ -318,7 +362,7 @@ Write this section for THIS project's actual brand and the assets audited above
### Text Animations ### Text Animations
Every text element in this beat must name a specific effect from the catalog at `skills/hyperframes/references/text-effects.md`. The catalog lists 24 effect IDs (from the separate `pixel-point/animate-text` skill); pick what fits the brand and this beat's mood — don't default to the same effect every beat. Every text element in this beat must name a specific effect from the catalog. The reference page is at [`../../hyperframes/references/text-effects.md`](../../hyperframes/references/text-effects.md) (or locate it with `find "$HOME" -path '*/hyperframes/references/text-effects.md' -maxdepth 10 2>/dev/null | head -1`). It lists 24 effect IDs (from the separate `pixel-point/animate-text` skill); pick what fits the brand and this beat's mood — don't default to the same effect every beat.
Format (FORMAT EXAMPLES of structure, not prescriptions — pick based on brand/mood/context): Format (FORMAT EXAMPLES of structure, not prescriptions — pick based on brand/mood/context):
@@ -524,3 +568,5 @@ Summarize the plan clearly. Don't dump the full STORYBOARD.md — give the user
### Gate ### Gate
Both STORYBOARD.md and SCRIPT.md exist AND the user has explicitly approved the plan. Both STORYBOARD.md and SCRIPT.md exist AND the user has explicitly approved the plan.
**Autonomous mode exception:** if the user signaled autonomous mode in Step 2 ("surprise me" / "decide for me" / "just build it"), skip the approval wait. Present the storyboard summary inline as a heads-up and proceed straight to Step 4.
@@ -32,7 +32,13 @@ npx hyperframes tts "First sentence. Second sentence." --voice af_nova --output
# Measure: seconds ÷ words × total script words = estimated full audio length # Measure: seconds ÷ words × total script words = estimated full audio length
``` ```
If the estimate puts your video at ±15% of the planned duration, proceed. If it's more than 15% off, recalibrate the script length first. If the estimate puts your video at ±15% of the planned duration, proceed. If it's more than 15% off, recalibrate the script length first:
- **Audio TOO SHORT** (more than 15% under planned duration) → add strategic pauses. In `narration.txt`, insert blank lines between paragraphs (≈0.6s each) or `...` between sentences (≈0.4s each). Aim for the pauses to land at storyboard beat boundaries so the silence feels intentional, not dead air.
- **Audio TOO LONG** (more than 15% over planned duration) → identify the beat in your storyboard with the highest words-per-second density. Cut one supporting sentence from THAT beat's lines — preserve the lead sentence (the one that names the beat's idea). Re-measure with another test clip before committing to full generation.
- **Audio matches plan but beat boundaries drift** → adjust the storyboard durations to match the actual narration, not the other way around. The audio is the ground truth once narration is generated.
The script formula assumes constant words-per-second, but punctuation, dramatic pauses, and silence cues all stretch real audio. Always trust a measured test clip over the formula.
## Background music ## Background music
@@ -298,9 +298,13 @@ Each sub-agent reads [beat-builder-guide.md](beat-builder-guide.md) — it has e
``` ```
Build the composition for Beat N. Save to compositions/beat-N-name.html. Build the composition for Beat N. Save to compositions/beat-N-name.html.
FIRST: Read skills/website-to-hyperframes/references/beat-builder-guide.md end to end. FIRST: Locate and read the beat-builder guide. Your CWD is the project directory, so
It has your full workflow, all rules, easing vocabulary, and file references. the skill lives outside it — run this to find it:
Follow its workflow exactly:
find "$HOME" -path '*/website-to-hyperframes/references/beat-builder-guide.md' -maxdepth 10 2>/dev/null | head -1
Read that file end to end. It has your full workflow, all rules, easing vocabulary,
and file references. Follow its workflow exactly:
build → lint (`npx hyperframes lint .`) build → lint (`npx hyperframes lint .`)
→ snapshot (`npx hyperframes snapshot . --frames 3`) → snapshot (`npx hyperframes snapshot . --frames 3`)
→ view contact sheet AND read snapshots/descriptions.md → view contact sheet AND read snapshots/descriptions.md
@@ -385,30 +389,86 @@ For every `.html` file in `compositions/`, confirm that `index.html` has a `data
**Captions stub rule:** Never create a `compositions/captions.html` with an empty transcript (`const script = [];`). If the VO/transcript step was skipped or failed, do not create the captions composition at all. An empty captions file that returns immediately is worse than no captions file — it silently does nothing and wastes a track slot. **Captions stub rule:** Never create a `compositions/captions.html` with an empty transcript (`const script = [];`). If the VO/transcript step was skipped or failed, do not create the captions composition at all. An empty captions file that returns immediately is worse than no captions file — it silently does nothing and wastes a track slot.
### Parallel sub-agent snapshots are stale — re-snapshot after all complete
When you dispatch sub-agents in parallel (one per beat), each sub-agent snapshots a project where sibling beats may not exist yet. Their per-beat snapshots are valid for THEIR beat in isolation, but **any snapshot at a beat boundary or during a shader transition will show the wrong content** — typically the previous beat's content because the next beat hasn't been built.
Example: Beat 6's sub-agent took a snapshot at t=25.7s and saw Beat 1's content because Beat 5 didn't exist yet when Beat 6's sub-agent ran. The sub-agent reported this as "shader transition behavior showing previous scene" — a plausible-sounding but wrong diagnosis.
**Required after all sub-agents complete:**
```bash
node /<repo-root>/packages/cli/dist/cli.js snapshot <project-dir> --frames <N>
```
where N follows the snapshot formula: `max(beats × 3, ceil(duration_seconds / 2))`. This is the canonical snapshot that Step 6's DoD uses — not any individual sub-agent's intermediate snapshots.
Sub-agents' snapshots are still useful as per-beat sanity checks, but they are not the deliverable. The post-completion snapshot is. Don't skip it because "the sub-agents already snapshotted."
## 5. Read each beat HTML top-to-bottom — REQUIRED gate before Step 6 ## 5. Read each beat HTML top-to-bottom — REQUIRED gate before Step 6
**Do not declare Step 5 complete on sub-agents' word.** Earlier sessions had sub-agents reply "looks good, 0 errors" and the main agent trusted them — that's how videos shipped with mismatched colors, missing logos, headlines too small to read. Close the trust path by opening every file the sub-agent produced. **This gate is non-skippable.** "I read it and it looks fine", "the sub-agent confirmed", "the snapshots look right" are NOT acceptable. Snapshots are 3 frames out of 300+ in motion — they hide everything that goes wrong between them.
**Why this gate exists:** Earlier sessions had sub-agents reply "looks good, 0 errors" and the main agent trusted them — that's how videos shipped with mismatched colors, missing logos, headlines too small to read, and SFX firing 1 second late because the agent typed timestamps "by eye" instead of computing them.
For each `compositions/beat-N.html`: For each `compositions/beat-N.html`:
1. **Open the file and read it top-to-bottom.** Not a glance. Not a grep. Read the `<style>` block, then the markup, then the `<script>` block. Understand what's actually there. 1. **Open the file and read it top-to-bottom.** Not a glance. Not a grep. Read the `<style>` block, then the markup, then the `<script>` block.
2. **Cross-check against DESIGN.md:** 2. **Fill in this evidence block — every line, with quoted values from the file:**
- Does the `--bg` / primary background hex from DESIGN.md appear in the CSS or inline styles?
- Does the accent hex appear (if this beat uses an accent)?
- Are fonts the ones DESIGN.md specified? If `@font-face` is declared, does the path match a real file under `capture/assets/fonts/` or a published `@fontsource/*` import?
- Is the headline `font-size` ≥80px?
3. **Cross-check against STORYBOARD.md (this beat's section):**
- Are the captured assets the storyboard called for actually referenced in the HTML (`<img src=...>`, inline SVG, `background-image: url(...)`, etc.)? Open the asset paths and confirm the files exist.
- Does the GSAP timeline cover the full beat duration, not just the first 1-2 seconds of entrance tweens? Look for events spread across the `BEAT` constant.
- Does the shot framing/camera move described in the storyboard show up in the GSAP code (scale/x/y/yPercent transforms with meaningful magnitudes)?
4. **Check the technical gates inline:**
- `data-composition-id` on the root div matches the `window.__timelines["..."]` key in the script
- `data-width` and `data-height` match the host div in index.html
- The script is INSIDE the `<template>`, not after `</template>`
- No bare `gsap.to(...)`, no `Math.random()`, no `repeat: -1`
5. **Open each frame in `snapshots/beat-N/`** and confirm visually that the entrance, hold, and exit moments look like what the storyboard described. If `snapshots/descriptions.md` exists, read Gemini's per-frame analysis of this beat in particular.
**Anything off — fix it inline (small CSS / GSAP correction) or re-dispatch the sub-agent with the specific problem quoted.** Do not move to Step 6 until every beat has been read top-to-bottom and the cross-checks pass. ```
Beat N: compositions/beat-N-NAME.html
BG color in CSS: <hex from line Y> ← quote the exact line
Accent color in CSS: <hex from line Z> ← quote the exact line
Headline font-size: <px from line> (≥80? yes/no)
Headline font-family: <stack from line> (matches DESIGN.md? yes/no)
@font-face src paths: <list> (each path exists? yes/no)
Captured assets used: <full list of paths from <img src=>, inline SVG ids, background-image url()>
Storyboard called for: <list from STORYBOARD.md beat N>
Assets match storyboard? yes/no — if no, specify the gap
GSAP first event: tl.X("...", {...}, <t>) beat-local t=<num>
GSAP last event: tl.X("...", {...}, <t>) beat-local t=<num>
Beat duration: <N>s (events span full duration? yes/no)
SFX trigger: <element> data-start=<num>
Storyboard SFX line: "<quote the line>" → expected t=<num>
SFX timestamp matches? yes/no — if no, specify the drift
Technical gates: data-composition-id matches window.__timelines key? yes/no
script INSIDE <template>? yes/no
no Math.random / no repeat:-1 / no bare gsap.to? yes/no
VERDICT: PASS / FIX (specify exactly what)
```
If you cannot fill any line (e.g., "I see no SFX trigger" or "headline font-family not specified"), that IS a finding — fix or escalate, don't paper over.
3. **Open each frame in `snapshots/beat-N/`** and confirm visually that entrance/hold/exit match the storyboard.
**Anything off — fix it inline (small CSS/GSAP correction) or re-dispatch the sub-agent with the specific problem quoted.** Do not move to Step 6 until every beat has its evidence block filled and PASS.
### SFX timestamp computation — compute, don't eyeball
Every SFX `data-start` value MUST be computed from STORYBOARD.md, not estimated visually.
For each SFX entry:
1. Storyboard names beat-local time (e.g. "Beat 2 at 1.2s into the beat").
2. Get the beat's global start time from beat ordering (e.g. Beat 1: 03.5s → Beat 2 starts at 3.5s globally).
3. Add beat-local + global start: `3.5 + 1.2 = 4.7s`.
4. Write `data-start="4.7"` in index.html.
**Forbidden:** writing `data-start="<approximate visual moment>"` by reading the storyboard and estimating by eye. The evidence block above MUST quote both the storyboard SFX line and the index.html `data-start` line — and confirm they match within ±0.1s (≈3 frames at 30fps; same tolerance `w2h-verify.mjs` enforces and `step-6-validate.md` uses for playback verification). A 1-second drift is not a rounding error; it's a build failure.
### Surface recurring sub-agent workarounds to the user
When 2+ sub-agents independently report the same workaround (e.g., "I had to base64-encode the data URI because the linter false-positives on inline SVG"), that's a tooling bug worth surfacing. List these in your Step 5 final report under "Tooling issues encountered" even if each instance was resolved. Format:
```
TOOLING ISSUES (worth filing):
- 3 sub-agents (beats 1, 4, 6) hit `root_missing_composition_id` false positive on
inline SVG data URI in CSS. Workarounds: base64 (beat 1), removed overlay (beats 4, 6).
Worth filing as a regression against packages/core/src/lint.
```
Burying recurring workarounds means the next session hits the same bug and works around it again. Don't.
### Brand-defaults check (whole-video, after every beat passes its own read) ### Brand-defaults check (whole-video, after every beat passes its own read)
@@ -17,9 +17,49 @@ Score each item 15. If any item scores below 3, fix it before continuing. **D
[ ] No mid-video dark frames → state explicitly which frames (if any) are dark and why [ ] No mid-video dark frames → state explicitly which frames (if any) are dark and why
[ ] Brand assets actually visible → for each beat, name which captured SVG / illustration / screenshot is on screen and at what timestamp. If a beat shows zero captured assets, justify why. [ ] Brand assets actually visible → for each beat, name which captured SVG / illustration / screenshot is on screen and at what timestamp. If a beat shows zero captured assets, justify why.
[ ] Audio duration matches video ±0.5s → paste both numbers [ ] Audio duration matches video ±0.5s → paste both numbers
[ ] animation-map.json generated → run `node <repo-root>/skills/hyperframes/scripts/animation-map.mjs <project-dir>`; confirm every beat has events listed and no bbox/flag warnings
[ ] w2h-verify report → run `node <repo-root>/skills/website-to-hyperframes/scripts/w2h-verify.mjs <project-dir>`; paste the FULL output (every row, every percent) verbatim into your final user-facing summary — see "w2h-verify — the source of truth" below
[ ] Audio + motion verification done → see "Audio + motion verification" below; played the full preview, confirmed SFX lands at storyboard timestamps
[ ] Critic sub-agent run → paste its single biggest quality gap finding, verbatim [ ] Critic sub-agent run → paste its single biggest quality gap finding, verbatim
``` ```
### w2h-verify — the source of truth
The skill ran for months on agents reading "REQUIRED" and skipping anyway. The verify script ends that — it computes facts the agent cannot fudge:
- **Required artifacts present** (STORYBOARD.md, DESIGN.md, SCRIPT.md, index.html)
- **Brand visuals used** — at least 1 beat must reference a captured `hero-*`, `image-*`, or `svgs/*.svg` asset (logo doesn't count). Catches the "9% asset usage / brand isn't visually present" failure.
- **Headline font-size** — per-beat, the largest CSS `font-size` must be ≥80px. Catches the "headlines too small to read" failure that surfaces later as inspect `clipped_text` errors.
- **Timeline coverage** — per-beat, GSAP event positions must span ≥70% of the beat's `data-duration`. Catches "webpage not shot" failures where the beat has entrance tweens then goes static.
- **Shader transitions consistency** — shaders declared in STORYBOARD.md must appear in index.html (with HyperShader runtime present, not just as SFX file references).
- **SFX timestamp drift** — storyboard `t=X.Xs` vs index.html `data-start=X.X`, picks the closest index timestamp per file for multi-timestamp SFX.
- **Beat duration consistency** — storyboard's beat ranges (`B4 — Name | 16.600 21.000s |`) must match `data-duration` in index.html within ±0.5s. Catches storyboard staleness.
- **Rendered MP4 existence** — INFO only; flagged when claiming verified motion without rendering.
Run it as the LAST gate in your DoD pass, after fixing everything else:
```bash
node <repo-root>/skills/website-to-hyperframes/scripts/w2h-verify.mjs <project-dir>
```
(Locate the repo root from a project subdirectory: `find "$HOME" -path '*/skills/website-to-hyperframes/scripts/w2h-verify.mjs' -maxdepth 10 2>/dev/null | head -1`.)
**The script's output is the deliverable.** Paste the entire report — the table, the percentages, the FAIL lines — verbatim into your final user-facing summary, in the "What I verified" / "What I did NOT verify" section. The user will read it directly. You don't get to summarize, simplify, or omit rows.
**If any row says FAIL:**
- Either fix the underlying issue and re-run until the row says PASS
- Or include the FAIL row verbatim in your final summary's "What I did NOT verify" section with a one-sentence explanation of why you chose not to fix it
**Forbidden:**
- Hand-writing your own verification summary that doesn't match the script's output
- Cherry-picking which rows to include
- Replacing percentages with adjectives ("most assets used" instead of "8%")
- Running the script, seeing FAIL, and not mentioning it
The script's exit code is 0 (all pass) or 1 (one or more fail). If you ship with exit=1, the user knows from the report exactly what they're getting.
### Per-beat file read ### Per-beat file read
This is what verification means now: you open each `compositions/beat-N.html` and read it top-to-bottom against DESIGN.md and STORYBOARD.md. Step 5 already required this once before advancing here — repeat it here as the final check, in case fixes during Step 5 introduced new problems. This is what verification means now: you open each `compositions/beat-N.html` and read it top-to-bottom against DESIGN.md and STORYBOARD.md. Step 5 already required this once before advancing here — repeat it here as the final check, in case fixes during Step 5 introduced new problems.
@@ -62,9 +102,24 @@ Some are style suggestions you can safely ignore:
- **Deprecated attributes** (data-layer, data-end) — still work, just not preferred - **Deprecated attributes** (data-layer, data-end) — still work, just not preferred
- **Dense tracks** — informational, not a bug - **Dense tracks** — informational, not a bug
**WCAG contrast false positives** — the validator samples text colors at fixed timestamps. Elements that are at `opacity: 0` (pre-entrance) or mid-fade at those sample timestamps get measured against the background as if they were fully visible, which produces spurious contrast failures. Before changing a color to clear a WCAG warning, verify visually that the element is actually unreadable when on-screen at full opacity. If it's only flagged for pre-entrance / exit moments, the warning is a sampling artifact, not a real failure. Bumping the color to "fix" these false positives changes the brand identity for no real benefit. **WCAG contrast warnings — per-warning verification, not blanket dismissal.**
Don't blindly ignore 158 warnings. Don't blindly fix all of them either. Read them. The validator samples text colors at fixed timestamps. Elements at `opacity: 0` (pre-entrance) or mid-fade get measured as if fully visible — real false positives exist. BUT this is a per-warning judgment, not a blanket excuse.
**For EACH warning the validator emits, paste this block in your verdict:**
```
Warning N: <quote the validator output verbatim>
Element: <selector>
Sampled timestamp: t=<n>
At t=<n>, is this element on-screen at full opacity? (yes/no — confirm by viewing snapshot at that timestamp)
Verdict: REAL ISSUE / SAMPLING ARTIFACT (justify in one sentence)
Action: <hex change at line N> OR NONE because <reason>
```
**Forbidden:** writing "the N warnings are mostly transition-window false positives" without per-warning evidence. That phrasing alone fails the gate. The validator does not report 158 warnings as a group — it reports them individually, and you verify them individually.
Don't blindly ignore. Don't blindly fix. Verify each.
## Visual Verification (snapshot) ## Visual Verification (snapshot)
@@ -86,7 +141,9 @@ npx hyperframes snapshot <project-dir> --frames <N> \
Output lands in `<project-dir>/snapshots/`. Gemini writes `snapshots/descriptions.md` automatically. Output lands in `<project-dir>/snapshots/`. Gemini writes `snapshots/descriptions.md` automatically.
**If `descriptions.md` is missing or empty after the snapshot:** `GEMINI_API_KEY` was not set — confirm it's in `<project-dir>/.env` (the CLI loads .env from CWD) or in your shell environment. Re-run after fixing. Do not proceed without Gemini descriptions — visual inspection alone is not sufficient verification. **If `descriptions.md` is missing or empty after the snapshot:** `GEMINI_API_KEY` was not set — confirm it's in `<project-dir>/.env` (the CLI loads .env from CWD) or in your shell environment. Re-run after fixing.
**Fallback if Gemini is genuinely unavailable** (no key, key invalid, or quota exhausted): use your own image-reading capability to inspect each frame in `snapshots/` directly. For each frame, write one sentence describing what's on screen — focus on the dimensions Gemini would catch (blank/dark frames, missing brand assets, text legibility, layout problems). Save these descriptions as `snapshots/descriptions.md` yourself so the rest of the checklist still has a single source of truth. State explicitly in your verdict that descriptions were agent-authored, not Gemini-authored, so the user knows to spot-check.
**Gemini descriptions will flag two frames as "blank/black" — these two are expected and not bugs:** **Gemini descriptions will flag two frames as "blank/black" — these two are expected and not bugs:**
@@ -154,6 +211,53 @@ If you cannot find any problems and want to score everything 45, you are not
Read every score. Fix anything below 3 before showing the user. If the CTA scores below 3, fix the CTA. Do not rationalize low scores as "the user can decide." Read every score. Fix anything below 3 before showing the user. If the CTA scores below 3, fix the CTA. Do not rationalize low scores as "the user can decide."
## Audio + motion verification — three paths, pick one
Snapshots are silent stills. 18 PNG snapshots from a 30s 30fps video = 18/900 = 2% of frames. The other 98% — including all motion, all transitions, all audio — is unverified by snapshots alone. "Confirmed via snapshot" is not coverage.
You MUST do ONE of these three paths before declaring done:
### Path 1 (preferred): Play the preview
Open the Studio URL in a browser via Playwright (or another browser tool you have). Play start-to-end at 1.0× speed (NOT scrubbed). Confirm:
```
[ ] Played full video front-to-back at 1.0× — actually played, not scrubbed
[ ] For each SFX in STORYBOARD.md: sound lands at the visual moment within ±0.1s
(Beat N SFX `<file>`: storyboard says t=<x>s → heard at t=<y>s → drift <z>s)
[ ] Narration delivers the right line per beat (no off-by-one or missing lines)
[ ] No moments where audio is present but visual is mid-transition unintentionally
[ ] Audio audible and not clipped/peaked
```
### Path 2: Render a low-res MP4 and read it frame-by-frame
When Playwright isn't available, render at 540p (fast — ~30s for a 30s video) and read the MP4:
```bash
node /<repo-root>/packages/cli/dist/cli.js render <project-dir> \
--width 960 --height 540 --quality medium
```
Then sample the resulting MP4 at minimum 5fps (use `ffmpeg -i <mp4> -r 5 frames/frame-%04d.png` if needed). Read those frames sequentially. For each SFX moment in STORYBOARD.md, find the corresponding frame and confirm the visual matches.
### Path 3 (last resort): Explicit deferred disclosure with quantified gap
If neither Path 1 nor Path 2 is possible in this session, your final summary MUST contain this verbatim:
```
**Audio + motion verification: NOT POSSIBLE in this session.**
- Snapshots cover: <N> frames out of <video_duration × fps> total (<percentage>% coverage)
- NOT verified: motion between snapshots, SFX/visual timing alignment, shader transition smoothness, audio mix levels, narration sync to beats
- Recommended user action: open the preview URL above and play start-to-end; flag anything that feels off
```
**Forbidden everywhere:**
- "Confirmed via snapshot" or "snapshots look right" as audio/motion evidence
- "Preview is running, looks good" without actually playing it
- Path 3 disclosure that omits the quantified coverage gap (the percentage is mandatory)
## Preview (always do this) ## Preview (always do this)
Always start the preview so the user can see and scrub through the project: Always start the preview so the user can see and scrub through the project:
@@ -170,6 +274,35 @@ http://localhost:<port>/#project/<project-name>
Use the actual port and project name from the preview command output. Do NOT present `index.html` as the project link — that's the source file. The user-facing project is the running Studio preview. Use the actual port and project name from the preview command output. Do NOT present `index.html` as the project link — that's the source file. The user-facing project is the running Studio preview.
### Honest disclosure — REQUIRED in your final summary
Your final message to the user MUST end with these two sections, even if everything passed. Both sections appear AFTER the preview URL, BEFORE you stop talking.
```
**What I verified:**
- <one bullet per DoD item that passed, with the actual evidence cited inline>
(e.g. "Lint: zero errors — output pasted above")
(e.g. "Per-beat read: 7/7 beats PASS, evidence blocks above")
(e.g. "WCAG: 3 warnings flagged, all 3 verified as sampling artifacts — see verdicts above")
**What I did NOT verify (spot-check these):**
- <one bullet per item you skipped, deferred, or could not complete — and why>
(e.g. "Audio + motion verification deferred — no Playwright in this session. SFX timing is computed but unconfirmed in playback.")
(e.g. "animation-map.json skipped — script not found at expected path; manually confirmed timeline coverage in per-beat reads instead.")
(e.g. "Beat 5 has a 0.4s window where the doc card is visible but contents are still opacity 0 — sub-agent flagged it, I chose not to fix because it was below my threshold; worth your eye.")
```
The user reads this section to know what to spot-check.
**UNACCEPTABLE final summaries:**
- "Looks great, ready to ship" (no disclosure)
- "All checks pass" (when one was actually skipped)
- "Sub-agents confirmed everything" (delegating trust without verifying)
- Omitting the "What I did NOT verify" section because you happened to verify everything (still include it — write "None" if true, but the section header must appear).
Lying or omitting here is worse than skipping a check honestly. A short user spot-check beats a hidden broken video every time.
## Render (on-demand only) ## Render (on-demand only)
**Do NOT render automatically.** Preview is the delivery — the user scrubs, spots tweaks, and you iterate. Rendering takes minutes per pass and is wasted if the user wants changes. **Do NOT render automatically.** Preview is the delivery — the user scrubs, spots tweaks, and you iterate. Rendering takes minutes per pass and is wasted if the user wants changes.
@@ -0,0 +1,759 @@
#!/usr/bin/env node
// w2h-verify.mjs — verification report for a website-to-hyperframes project.
//
// Computes quality signals the agent cannot fudge. Each check is designed to
// catch a specific failure mode observed across three real agent debriefs.
// Result becomes the Step 6 deliverable — paste verbatim into the final
// user-facing summary so the user sees exactly what shipped.
//
// Pure file analysis. No shell spawns. Run lint + inspect separately and
// paste alongside.
//
// Usage:
// node skills/website-to-hyperframes/scripts/w2h-verify.mjs <project-dir>
//
// Exit codes:
// 0 = all gates pass
// 1 = one or more gates failed
// 2 = script error (project-dir missing, etc.)
import { readFile, readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { existsSync } from "node:fs";
const PROJECT_DIR = resolve(process.argv[2] || ".");
// Thresholds — change here, not by interpretation.
const HEADLINE_MIN_PX = 80; // 80px floor for primary headline at 1920×1080
const TIMELINE_COVERAGE_MIN = 0.7; // max GSAP event position must reach ≥70% of beat duration
const SFX_DRIFT_TOLERANCE_S = 0.1; // 3 frames at 30fps; matches step-5 evidence rule + step-6 playback floor
const BEAT_DURATION_DRIFT_TOLERANCE_S = 0.5;
const SHADER_NAMES = [
"cross-warp-morph",
"cross-warp",
"cinematic-zoom",
"gravitational-lens",
"glitch",
"light-leak",
"flash-through-white",
"whip-pan",
"domain-warp",
"thermal-bloom",
"swirl",
"ridged-noise",
"sdf-reveal",
"chromatic-aberration",
"ripple",
];
// ─── Main ────────────────────────────────────────────────────────────────────
async function main() {
if (!existsSync(PROJECT_DIR)) {
console.error(`✗ Project directory not found: ${PROJECT_DIR}`);
process.exit(2);
}
const results = [];
results.push(await checkRequiredArtifacts());
results.push(await checkBrandVisualsUsed());
results.push(await checkPerBeatHeadlineSize());
results.push(await checkPerBeatTimelineCoverage());
results.push(await checkShaderTransitionsConsistency());
results.push(await checkSfxTimestampConsistency());
results.push(await checkBeatDurationConsistency());
results.push(await checkMp4Exists());
printReport(results);
const anyFail = results.some((r) => r.status === "FAIL");
process.exit(anyFail ? 1 : 0);
}
// ─── Checks ──────────────────────────────────────────────────────────────────
async function checkRequiredArtifacts() {
const required = ["STORYBOARD.md", "DESIGN.md", "SCRIPT.md", "index.html"];
const missing = required.filter((f) => !existsSync(join(PROJECT_DIR, f)));
if (missing.length === 0) {
return { name: "Required artifacts", status: "PASS", detail: required.join(", ") };
}
return {
name: "Required artifacts",
status: "FAIL",
detail: `missing: ${missing.join(", ")}`,
};
}
// Catches the biggest failure mode: agent uses only the logo and rebuilds
// every "hero illustration" from CSS, ignoring the brand's actual visual identity.
async function checkBrandVisualsUsed() {
const compositions = await readBeatCompositions();
if (compositions.length === 0) {
return { name: "Brand visuals used", status: "INFO", detail: "no beat compositions found" };
}
// A "brand visual" is anything captured under capture/assets/ that ISN'T:
// - a font file
// - a logo file (filename contains "logo")
// - a favicon / apple-touch-icon
// i.e., hero-*.jpg, image-*.png, illustrations, photographs, svgs/<icons>.svg
const brandRegex =
/capture\/assets\/(?!fonts\/)(?:svgs\/)?(?!.*(?:logo|favicon|apple-touch-icon))[\w-]+\.(?:jpg|jpeg|png|svg|webp|gif)/gi;
const beatsUsingBrand = [];
for (const beat of compositions) {
const matches = beat.content.match(brandRegex) || [];
if (matches.length > 0) {
beatsUsingBrand.push({ beat: beat.name, count: new Set(matches).size });
}
}
const pass = beatsUsingBrand.length >= 1;
if (pass) {
return {
name: "Brand visuals used",
status: "PASS",
detail: `${beatsUsingBrand.length}/${compositions.length} beats reference a hero/image/svg captured asset`,
extra:
beatsUsingBrand.length === 1
? "Only 1 beat uses a captured visual — consider whether the brand's hero illustrations or signature graphics fit other beats too."
: null,
};
}
return {
name: "Brand visuals used",
status: "FAIL",
detail: `0/${compositions.length} beats reference any hero-*/image-*/svgs/ captured asset (logo doesn't count)`,
extra:
"Open capture/assets/contact-sheet-*.jpg and capture/assets/svgs/contact-sheet-*.jpg. The brand's actual visuals are sitting there. Rebuilding them in CSS erases what makes the brand recognizable.",
};
}
// Catches "headlines too small to read" — inspect typically catches the
// overflow afterward, but this catches the cause earlier.
async function checkPerBeatHeadlineSize() {
const compositions = await readBeatCompositions();
if (compositions.length === 0) {
return { name: "Headline font-size", status: "INFO", detail: "no beat compositions found" };
}
// Only flag beats where the LARGEST font-size is in the "aspiring headline
// but too small" range (40<80px). Below 40px = the beat has no headline by
// design (UI labels, code text, decorative). ≥80px = proper headline.
// This skips legitimate non-headline beats (terminal beats, SVG-only beats,
// pure-image beats) without losing the real "headline too small" signal.
const HEADLINE_FLOOR_FOR_CHECK = 40;
const offenders = [];
const skipped = [];
for (const beat of compositions) {
const sizes = [...beat.content.matchAll(/font-size:\s*(\d+(?:\.\d+)?)px/g)].map((m) =>
parseFloat(m[1]),
);
if (sizes.length === 0) {
skipped.push({ beat: beat.name, reason: "no font-size declared" });
continue;
}
const max = Math.max(...sizes);
if (max < HEADLINE_FLOOR_FOR_CHECK) {
skipped.push({ beat: beat.name, reason: `largest font is ${max}px — no headline by design` });
continue;
}
if (max < HEADLINE_MIN_PX) {
offenders.push({ beat: beat.name, maxSize: max });
}
}
const pass = offenders.length === 0;
const checked = compositions.length - skipped.length;
return {
name: "Headline font-size",
status: pass ? "PASS" : "FAIL",
detail: pass
? `${checked}/${compositions.length} beats with a headline-sized text element, all ≥${HEADLINE_MIN_PX}px`
: `${offenders.length} beat(s) with headline-sized text below ${HEADLINE_MIN_PX}px floor`,
extra: pass
? null
: [
...offenders.map((o) => `${o.beat}: largest font-size is ${o.maxSize}px`),
...(skipped.length > 0
? [
`(skipped ${skipped.length} beat(s) with no headline-sized text: ${skipped.map((s) => s.beat).join(", ")})`,
]
: []),
].join("\n "),
};
}
// Catches "webpage not shot" failures — entrance tweens in the first second
// then nothing. Snapshots look fine (static end state) but motion is dead.
async function checkPerBeatTimelineCoverage() {
const compositions = await readBeatCompositions();
const beatDurations = await readBeatDurationsFromIndex();
if (compositions.length === 0) {
return { name: "Timeline coverage", status: "INFO", detail: "no beat compositions found" };
}
const offenders = [];
const skipped = [];
// Pattern detectors: beats that use these idioms have events at positions
// the static parser can't read (loop iterators, variable arithmetic).
// Don't flag them as "webpage not shot" — they have events the parser
// simply can't see. Note: single-tween yoyo/repeat is NOT enough to skip
// — it only oscillates one element, not the whole beat.
const dynamicPatterns = [
{
name: "forEach with tweens",
re: /\.forEach\s*\([^{]*\{[\s\S]{0,2000}?\btl\.(?:to|set|fromTo|from)\(/,
},
{
name: "for-loop with tweens",
re: /\bfor\s*\([^)]*\)\s*\{[\s\S]{0,2000}?\btl\.(?:to|set|fromTo|from)\(/,
},
];
for (const beat of compositions) {
const beatId = beat.name.replace(/\.html$/, "");
const dur = beatDurations[beatId] ?? beatDurations[beat.name];
if (!dur) {
skipped.push({ beat: beat.name, reason: "no data-duration in index.html" });
continue;
}
// Check for dynamic patterns first — if present, coverage cannot be
// statically determined; treat as informational, not a failure.
const matchedDynamic = dynamicPatterns.find((p) => p.re.test(beat.content));
if (matchedDynamic) {
skipped.push({
beat: beat.name,
reason: `dynamic event pattern detected (${matchedDynamic.name}) — coverage not statically measurable`,
});
continue;
}
// Long-duration tween check: if there's a tween with duration ≥ 70% of
// beat duration, that single tween covers the whole beat — likely a
// camera dolly, breathing animation, or persistent motion. Skip the
// position-based coverage check.
const durationRe = /\btl\.(?:to|set|fromTo|from)\([\s\S]{0,500}?duration:\s*([0-9.]+)/g;
let dm;
let hasLongTween = false;
let longTweenDur = 0;
while ((dm = durationRe.exec(beat.content)) !== null) {
const d = parseFloat(dm[1]);
if (d >= dur * TIMELINE_COVERAGE_MIN) {
hasLongTween = true;
longTweenDur = d;
break;
}
}
if (hasLongTween) {
skipped.push({
beat: beat.name,
reason: `long-duration tween covers ${longTweenDur.toFixed(2)}s of ${dur.toFixed(2)}s beat — full coverage via persistent motion`,
});
continue;
}
// Extract GSAP event positions for static cases.
// Use balanced-paren scanning so multi-line calls and rgba(...) values
// inside option objects don't false-match.
const positions = extractTopLevelPositionArgs(beat.content);
if (positions.length === 0) {
offenders.push({ beat: beat.name, reason: "no GSAP events with explicit position found" });
continue;
}
const maxPos = Math.max(...positions);
const coverage = maxPos / dur;
if (coverage < TIMELINE_COVERAGE_MIN) {
offenders.push({
beat: beat.name,
reason: `static events span 0${maxPos.toFixed(2)}s of ${dur.toFixed(2)}s beat (${Math.round(coverage * 100)}%)`,
});
}
}
const pass = offenders.length === 0;
const checked = compositions.length - skipped.length;
return {
name: "Timeline coverage",
status: pass ? "PASS" : "FAIL",
detail: pass
? `${checked}/${compositions.length} statically-measurable beats span ≥${Math.round(TIMELINE_COVERAGE_MIN * 100)}% of duration`
: `${offenders.length} beat(s) below ${Math.round(TIMELINE_COVERAGE_MIN * 100)}% static coverage — likely "webpage not shot" failures`,
extra:
[
...offenders.map((o) => `${o.beat}: ${o.reason}`),
...(skipped.length > 0 ? skipped.map((s) => `(skipped ${s.beat}: ${s.reason})`) : []),
].join("\n ") || null,
};
}
async function checkShaderTransitionsConsistency() {
const storyboardPath = join(PROJECT_DIR, "STORYBOARD.md");
const indexPath = join(PROJECT_DIR, "index.html");
if (!existsSync(storyboardPath) || !existsSync(indexPath)) {
return {
name: "Shader transitions",
status: "INFO",
detail: "STORYBOARD.md or index.html missing — cannot check",
};
}
const storyboard = await readFile(storyboardPath, "utf-8");
const index = await readFile(indexPath, "utf-8");
// For each shader name, count it as "declared" only if it appears in a
// transition-use context — NOT in an inventory list (3+ names on one line)
// and NOT exclusively as part of an SFX filename (sfx/glitch-1.mp3).
const sbLines = storyboard.split("\n");
// Longest-name matching to avoid substring double-counting.
const sortedNames = [...SHADER_NAMES].sort((a, b) => b.length - a.length);
const seen = new Set();
const declared = [];
for (const name of sortedNames) {
let foundInUseContext = false;
for (const line of sbLines) {
if (seen.has(name)) break;
if (!line.includes(name)) continue;
// Skip inventory listing lines (3+ shader names on one line).
const namesOnLine = SHADER_NAMES.filter((n) => line.includes(n)).length;
if (namesOnLine >= 3) continue;
// Skip lines where the shader name only appears in SFX filename context.
const sfxContext = line.includes(`sfx/${name}`) || line.includes(`sfx-${name}`);
// Count this name if it appears in a non-SFX, non-inventory line.
if (!sfxContext) {
foundInUseContext = true;
break;
}
}
if (foundInUseContext) {
declared.push(name);
seen.add(name);
}
}
if (declared.length === 0) {
return {
name: "Shader transitions",
status: "PASS",
detail: "STORYBOARD declared none — no shader transitions expected",
};
}
// A shader is "present" only if HyperShader runtime is in index.html AND the
// shader name appears in a line that is NOT an SFX reference.
const hasHyperShader = /HyperShader\s*[(.]/.test(index);
const indexLines = index.split("\n");
const present = !hasHyperShader
? []
: declared.filter((name) =>
indexLines.some((line) => {
if (!line.includes(name)) return false;
if (line.includes(`sfx/${name}`) || line.includes(`sfx-${name}`)) return false;
return true;
}),
);
const missing = declared.filter((name) => !present.includes(name));
const pass = missing.length === 0;
return {
name: "Shader transitions",
status: pass ? "PASS" : "FAIL",
detail: `STORYBOARD declared ${declared.length}, ${present.length} present in index.html, ${missing.length} missing`,
extra: pass
? null
: `Missing from build: ${missing.join(", ")}. STORYBOARD.md and index.html disagree — either re-add the transitions or update STORYBOARD.md.`,
};
}
async function checkSfxTimestampConsistency() {
const storyboardPath = join(PROJECT_DIR, "STORYBOARD.md");
const indexPath = join(PROJECT_DIR, "index.html");
if (!existsSync(storyboardPath) || !existsSync(indexPath)) {
return {
name: "SFX timestamps",
status: "INFO",
detail: "STORYBOARD.md or index.html missing",
};
}
const storyboard = await readFile(storyboardPath, "utf-8");
const index = await readFile(indexPath, "utf-8");
const sfxRefs = [];
for (const line of storyboard.split("\n")) {
const fileMatch = line.match(/sfx\/([\w-]+\.mp3)/);
// Require trailing `s` so we capture the time column, not the volume column.
const timeMatch = line.match(/\|\s*(\d+(?:\.\d+)?)s\b/);
if (fileMatch && timeMatch) {
sfxRefs.push({ file: fileMatch[1], storyboardT: parseFloat(timeMatch[1]) });
}
}
if (sfxRefs.length === 0) {
return {
name: "SFX timestamps",
status: "INFO",
detail: "No SFX entries detected in STORYBOARD.md",
};
}
// Collect ALL audio tags per file (multi-timestamp SFX like click.mp3 have
// 3 tags). Use a two-step extraction so we don't depend on src= and
// data-start= attribute ordering — the documented canonical pattern in
// capabilities.md puts src= LAST in the tag, which an order-dependent
// regex would miss → false MISSING reports.
const indexSfx = new Map();
const audioTagRegex = /<audio[^>]*?>/g;
let tagMatch;
while ((tagMatch = audioTagRegex.exec(index)) !== null) {
const tag = tagMatch[0];
const srcMatch = tag.match(/src=["'](?:[^"']*\/)?sfx\/([\w-]+\.mp3)["']/);
const dsMatch = tag.match(/data-start=["']([0-9.]+)["']/);
if (!srcMatch || !dsMatch) continue;
const file = srcMatch[1];
const t = parseFloat(dsMatch[1]);
if (!indexSfx.has(file)) indexSfx.set(file, []);
indexSfx.get(file).push(t);
}
const drifts = [];
const missing = [];
for (const ref of sfxRefs) {
if (!indexSfx.has(ref.file)) {
missing.push(ref.file);
continue;
}
const indexTs = indexSfx.get(ref.file);
const closest = indexTs.reduce((best, t) =>
Math.abs(t - ref.storyboardT) < Math.abs(best - ref.storyboardT) ? t : best,
);
const drift = Math.abs(closest - ref.storyboardT);
if (drift > SFX_DRIFT_TOLERANCE_S) {
drifts.push({ file: ref.file, storyboardT: ref.storyboardT, indexT: closest, drift });
}
}
const indexCount = [...indexSfx.values()].reduce((sum, arr) => sum + arr.length, 0);
const pass = missing.length === 0 && drifts.length === 0;
return {
name: "SFX timestamps",
status: pass ? "PASS" : "FAIL",
detail: `${sfxRefs.length} SFX in STORYBOARD · ${indexCount} in index.html · ${missing.length} missing · ${drifts.length} drifted >${SFX_DRIFT_TOLERANCE_S}s`,
extra: pass
? null
: [
...missing.map((f) => `MISSING in index.html: ${f}`),
...drifts.map(
(d) =>
`DRIFT: ${d.file} storyboard=${d.storyboardT}s closest index=${d.indexT}s drift=${d.drift.toFixed(2)}s`,
),
].join("\n "),
};
}
// Catches storyboard staleness on beat timings — agent shipped with different
// beat durations than the storyboard documented, leaving the spec lying.
async function checkBeatDurationConsistency() {
const storyboardPath = join(PROJECT_DIR, "STORYBOARD.md");
if (!existsSync(storyboardPath)) {
return { name: "Beat durations", status: "INFO", detail: "STORYBOARD.md missing" };
}
const storyboard = await readFile(storyboardPath, "utf-8");
const indexDurations = await readBeatDurationsFromIndex();
// Filter to only numbered beats (beat-1, beat-2, ...). Skip "main" / root
// composition and any non-numbered placeholders.
const buildBeatIds = Object.keys(indexDurations).filter((id) => /^beat-\d+/i.test(id));
if (buildBeatIds.length === 0) {
return {
name: "Beat durations",
status: "INFO",
detail: "no numbered beat data-duration in index.html",
};
}
// Parse storyboard beat durations. The expected pattern is a timing-table row:
// | B4 — MetaBrain | 16.600 21.000s | ... |
// We extract startend and compute duration = end - start. Falls back to a
// direct duration mention ("duration: X.Xs") if the range format isn't found.
// A beat with no parseable duration is skipped — we don't flag missing rows.
const drifts = [];
const unparseable = [];
for (const beatId of buildBeatIds) {
const num = beatId.match(/beat-(\d+)/i)?.[1];
if (!num) continue;
let storyT = null;
// Strategy 1: standalone beat row "B4 — Name | 16.600 21.000s |".
// Require `B${num}` to be followed by a non-digit-non-dot char so "B3.1"
// doesn't false-match for B3.
const rangeRe = new RegExp(
`\\bB${num}(?![\\.\\d])[^\\n|]*\\|\\s*(\\d+(?:\\.\\d+)?)s?\\s*[-]\\s*(\\d+(?:\\.\\d+)?)s`,
"i",
);
const rm = storyboard.match(rangeRe);
if (rm) {
storyT = parseFloat(rm[2]) - parseFloat(rm[1]);
} else {
// Strategy 2: sum sub-beats "B${num}.X — ... | start end s |".
// Useful when a beat is broken into sub-rows (B3.1, B3.2, ...) instead
// of having a standalone row.
const subRe = new RegExp(
`\\bB${num}\\.\\d+\\b[^\\n|]*\\|\\s*(\\d+(?:\\.\\d+)?)s?\\s*[-]\\s*(\\d+(?:\\.\\d+)?)s`,
"gi",
);
let sum = 0;
let count = 0;
let mm;
while ((mm = subRe.exec(storyboard)) !== null) {
sum += parseFloat(mm[2]) - parseFloat(mm[1]);
count++;
}
if (count > 0) storyT = sum;
}
if (storyT === null) {
// Strategy 3: bare-number timing table — "| 1 | 0.00s | 5.20s | 5.20s |"
// (Beat | Start | End | Duration). The duration column is the 4th cell,
// OR derive from end - start (columns 2 and 3). Match a row that starts
// with `| <num> |` and has at least 2 time cells.
// Allow optional leading `>` (blockquote) before the pipe.
const tableRe = new RegExp(
`^\\s*>?\\s*\\|\\s*${num}\\s*\\|\\s*(\\d+(?:\\.\\d+)?)s?\\s*\\|\\s*(\\d+(?:\\.\\d+)?)s`,
"im",
);
const tm = storyboard.match(tableRe);
if (tm) {
const start = parseFloat(tm[1]);
const end = parseFloat(tm[2]);
storyT = end - start;
}
}
// Note: removed the previous "duration: X.Xs near beat label" fallback —
// it false-matched non-beat durations (e.g., "shader runs — duration 0.7s"
// near a "Beat 1" mention). If a storyboard's timing format isn't a clean
// range or table row, the beat is reported as unparseable rather than
// guessed at.
if (storyT === null) {
unparseable.push(beatId);
continue;
}
const buildT = indexDurations[beatId];
const drift = Math.abs(storyT - buildT);
if (drift > BEAT_DURATION_DRIFT_TOLERANCE_S) {
drifts.push({ beat: beatId, storyboardDuration: storyT, buildDuration: buildT, drift });
}
}
const parseable = buildBeatIds.length - unparseable.length;
if (drifts.length === 0) {
return {
name: "Beat durations",
status: "PASS",
detail: `${parseable}/${buildBeatIds.length} beats parseable, storyboard durations match within ±${BEAT_DURATION_DRIFT_TOLERANCE_S}s`,
extra:
unparseable.length > 0
? `(skipped: ${unparseable.join(", ")} — could not find duration in STORYBOARD.md)`
: null,
};
}
return {
name: "Beat durations",
status: "FAIL",
detail: `${drifts.length} beat(s) drift between STORYBOARD.md and index.html > ${BEAT_DURATION_DRIFT_TOLERANCE_S}s`,
extra: [
...drifts.map(
(d) =>
`${d.beat}: storyboard=${d.storyboardDuration.toFixed(2)}s build=${d.buildDuration}s drift=${d.drift.toFixed(2)}s`,
),
...(unparseable.length > 0
? [`(skipped: ${unparseable.join(", ")} — could not find duration in STORYBOARD.md)`]
: []),
].join("\n "),
};
}
async function checkMp4Exists() {
const candidates = [PROJECT_DIR, join(PROJECT_DIR, "output"), join(PROJECT_DIR, "renders")];
for (const dir of candidates) {
if (!existsSync(dir)) continue;
try {
const files = await readdir(dir);
if (files.some((f) => f.endsWith(".mp4"))) {
return {
name: "Rendered MP4",
status: "PASS",
detail: `found .mp4 in ${dir.replace(PROJECT_DIR, ".")}`,
};
}
} catch {
/* ignore */
}
}
return {
name: "Rendered MP4",
status: "INFO",
detail:
"no .mp4 found — preview-only delivery; if claiming verified motion, render is required (Path 2 of audio+motion verification)",
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// Cached so multiple checks don't re-read the same files. The script is a
// one-shot CLI so a process-scoped cache is fine; no invalidation needed.
let _compositionsCache = null;
async function readBeatCompositions() {
if (_compositionsCache) return _compositionsCache;
const dir = join(PROJECT_DIR, "compositions");
if (!existsSync(dir)) {
_compositionsCache = [];
return _compositionsCache;
}
const files = await readdir(dir);
const beats = files.filter((f) => /^beat-/i.test(f) && f.endsWith(".html"));
const out = [];
for (const f of beats) {
const content = await readFile(join(dir, f), "utf-8");
out.push({ name: f, content });
}
_compositionsCache = out;
return out;
}
// Extract the trailing numeric position argument from each `tl.<method>(...)`
// call in a script. Uses balanced-paren scanning so multi-line calls and
// nested patterns like rgba(86,131,218,0.35) don't false-match.
function extractTopLevelPositionArgs(content) {
const positions = [];
const methodRe = /\btl\.(?:to|set|fromTo|from|call|add)\(/g;
let startMatch;
while ((startMatch = methodRe.exec(content)) !== null) {
let i = startMatch.index + startMatch[0].length;
let depth = 1;
let lastTopLevelCommaIdx = -1;
let inString = false;
let stringChar = null;
while (i < content.length && depth > 0) {
const c = content[i];
if (inString) {
if (c === "\\") {
i += 2;
continue;
}
if (c === stringChar) inString = false;
i++;
continue;
}
if (c === '"' || c === "'" || c === "`") {
inString = true;
stringChar = c;
i++;
continue;
}
if (c === "(" || c === "{" || c === "[") depth++;
else if (c === ")" || c === "}" || c === "]") {
depth--;
if (depth === 0) break;
} else if (c === "," && depth === 1) lastTopLevelCommaIdx = i;
i++;
}
if (depth === 0 && lastTopLevelCommaIdx > 0) {
const lastArg = content.substring(lastTopLevelCommaIdx + 1, i).trim();
const numMatch = lastArg.match(/^([0-9.]+)$/);
if (numMatch) positions.push(parseFloat(numMatch[1]));
}
}
return positions;
}
// Returns a map of { "beat-1-name": durationInSeconds, ... } from index.html.
// Cached per-process (one-shot CLI, no invalidation needed).
let _beatDurationsCache = null;
async function readBeatDurationsFromIndex() {
if (_beatDurationsCache) return _beatDurationsCache;
const indexPath = join(PROJECT_DIR, "index.html");
if (!existsSync(indexPath)) {
_beatDurationsCache = {};
return _beatDurationsCache;
}
const content = await readFile(indexPath, "utf-8");
const map = {};
// Pattern: <div data-composition-id="beat-N-name" ... data-duration="5.5" ...>
// OR: data-composition-src="compositions/beat-N-name.html" ... data-duration="5.5"
const divRegex = /<div[^>]*?>/g;
let m;
while ((m = divRegex.exec(content)) !== null) {
const tag = m[0];
const idMatch = tag.match(/data-composition-id=["']([^"']+)["']/);
const srcMatch = tag.match(/data-composition-src=["'][^"']*?\/?(beat-[\w-]+)\.html["']/);
const durMatch = tag.match(/data-duration=["']([0-9.]+)["']/);
if (!durMatch) continue;
const dur = parseFloat(durMatch[1]);
if (idMatch) map[idMatch[1]] = dur;
if (srcMatch) map[srcMatch[1]] = dur;
}
_beatDurationsCache = map;
return map;
}
// ─── Report ──────────────────────────────────────────────────────────────────
function printReport(results) {
const cols = { name: 26, status: 8, detail: 60 };
const line = "─".repeat(cols.name + cols.status + cols.detail + 6);
console.log("");
console.log(`w2h-verify · ${PROJECT_DIR}`);
console.log(line);
console.log("Check".padEnd(cols.name) + " │ " + "Status".padEnd(cols.status) + " │ " + "Detail");
console.log(line);
for (const r of results) {
const symbol = r.status === "PASS" ? "✓" : r.status === "FAIL" ? "✗" : "·";
console.log(
r.name.padEnd(cols.name) +
" │ " +
`${symbol} ${r.status}`.padEnd(cols.status) +
" │ " +
(r.detail || ""),
);
if (r.extra) {
const indent = " ".repeat(cols.name + cols.status + 6 + 4);
console.log(
"".padEnd(cols.name) +
" │ " +
"".padEnd(cols.status) +
" │ " +
r.extra.split("\n").join("\n" + indent),
);
}
}
console.log(line);
const pass = results.filter((r) => r.status === "PASS").length;
const fail = results.filter((r) => r.status === "FAIL").length;
const info = results.filter((r) => r.status === "INFO").length;
console.log(`SUMMARY: ${pass} PASS · ${fail} FAIL · ${info} INFO`);
if (fail > 0) {
console.log("");
console.log("Step 6 NOT done. Fix FAIL items, OR include this report verbatim in your final");
console.log("summary's \"What I did NOT verify\" section so the user knows what's broken.");
} else {
console.log("");
console.log(
"All gates pass. Paste this report into your final user-facing summary as evidence.",
);
}
console.log("");
}
main().catch((e) => {
console.error("w2h-verify script error:", e);
process.exit(2);
});