mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-02 20:18:35 +00:00
fix(skill): w2h-verify v2 — kill false positives from real-agent debrief
A fresh agent session ran the v1 verify script and the disclosure pasted
into their final summary showed 3 FAIL rows for things that weren't
actually defects:
Headline font-size: flagged Beat 2 (wordmark SVG), Beat 3 (UI grid),
Beat 5 (terminal). None of these legitimately have text headlines.
Timeline coverage: flagged 5/6 beats because the script's regex only
saw `tl.X(..., 2.5)` literal positions and missed forEach loops,
variable-position tweens, and long-duration scaler tweens.
Beat durations: flagged 2 beats because my "duration X.Xs near beat
label" fallback false-matched non-beat durations
(e.g., "shader runs — duration 0.7s" near a "Beat 1" mention).
The agent had to write ~5 paragraphs defensively justifying each false
FAIL. That's friction we can fix.
Tested against the agent's actual project (huly-launch-v4): went from
4 FAIL (3 false positives + 1 real bare-table parser miss) to 0 FAIL.
Also re-verified huly-v3 still correctly catches its 3 real issues
(48px wordmark, missing shaders, 3 SFX drifts) — no regression.
**Brand visuals check**
Switched from "≥30% asset usage" (gameable, rewards quantity over quality)
to "at least 1 beat references a captured hero/image/svg" — quality
signal that's cheap to satisfy when real, hard to fake. Excludes fonts,
logos, favicons, contact-sheets.
**Headline check**
Now only flags beats where the LARGEST font-size is in the 40–<80px
range — the "aspiring headline but too small" zone. Below 40px = beat
has no text headline by design (terminal, UI labels, SVG-only); skip.
≥80px = proper headline; pass. Eliminates the false positives on
SVG-dominated and UI-grid beats while still catching the real "headline
too small" failure (Beat 4 at 72px in this run; Beat 1 wordmark at 48px
in another).
**Timeline coverage check**
Three improvements:
1. Detects forEach loops + for-loops containing tl.X() calls — beats
with these have events at positions the static parser can't read;
mark as INFO-skipped rather than failed.
2. Detects long-duration tweens — if a single tween's duration covers
≥70% of the beat duration (camera dolly, breathing animation), the
beat has full coverage via persistent motion; skip the position check.
3. New paren-balanced parser for extracting tl.X() position arguments —
the v1 regex was matching `rgba(86,131,218,0.35)` and capturing 0.35
as a tween position. The new parser walks paren depth and only
captures top-level trailing numeric args. No more rgba false matches.
**Shader transitions check**
Two fixes:
1. Filter out inventory lines — lines listing 3+ shader names are
"what's available," not "what's planned for use." Real use
mentions one or two shaders per line.
2. Apply the same SFX-context exclusion to the declared side that the
present-check side already had — "glitch" inside `sfx/glitch-1.mp3`
no longer counts as a declared shader transition.
For huly-v3: was 6 declared (1 phantom from inventory + 5 + glitch
from SFX), now 2 declared (light-leak, cinematic-zoom) — matches the
storyboard's actual plan.
**Beat duration check**
1. Dropped the "duration X.Xs within 200 chars of beat label" fallback
— too loose; matched shader durations, animation durations, anything
labeled "duration". This was the source of the 0.70s misread in the
debrief.
2. Added a bare-number timing-table parser for the format
`| 1 | 0.00s | 5.20s | 5.20s | ... |` (with optional `>` blockquote
prefix). Computes duration = end - start.
3. Added a negative lookahead so `\bB3\b` doesn't false-match "B3.1"
sub-beats and grab the wrong row.
4. Filter buildBeatIds to only numbered beats — skips the root
composition (`data-composition-id="main"`) so it doesn't inflate
"parseable" count.
**Brand visuals + asset count**
Excluded fonts/ subdirectory (always-used via @font-face → would
always pass) and contact-sheet-*.jpg (pipeline outputs, not website
inputs). Both inflated the denominator and weakened the signal.
**Edge case fixes**
- Removed `basename` unused import (oxlint).
- Fixed shader-name substring overlap: longest-name-first matching so
"cross-warp-morph" doesn't double-count as "cross-warp".
- SFX timestamps now collect ALL audio tags per file (multi-timestamp
SFX like click×3); picks closest index timestamp to each storyboard
timestamp instead of just keeping the last.
**Step 6 doc**
Updated the skill's "w2h-verify — the source of truth" section to
describe the new checks accurately and what failure mode each catches.
2 files changed, +486/-109. Format + lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
190f1ec71a
commit
f4a7961bc1
@@ -27,12 +27,14 @@ Score each item 1–5. If any item scores below 3, fix it before continuing. **D
|
||||
|
||||
The skill ran for months on agents reading "REQUIRED" and skipping anyway. The verify script ends that — it computes facts the agent cannot fudge:
|
||||
|
||||
- **Asset usage %** (assets referenced in compositions ÷ assets captured)
|
||||
- **Shader transitions consistency** (shaders declared in STORYBOARD.md vs shaders present in index.html)
|
||||
- **SFX timestamp drift** (storyboard `t=X.Xs` vs index.html `data-start=X.X`)
|
||||
- **animation-map.json existence**
|
||||
- **Rendered MP4 existence**
|
||||
- **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:
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// w2h-verify.mjs — verification report for a website-to-hyperframes project.
|
||||
//
|
||||
// Outputs facts the agent cannot fudge: asset usage %, storyboard-vs-build
|
||||
// consistency, artifact existence. Result becomes the Step 6 deliverable —
|
||||
// paste verbatim into the final user-facing summary.
|
||||
// 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.
|
||||
//
|
||||
// This script does PURE FILE ANALYSIS — it does not spawn lint/inspect.
|
||||
// Run those separately via the CLI and include their summaries alongside.
|
||||
// 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 (final summary must disclose)
|
||||
// 2 = script error (project-dir not found, etc.)
|
||||
// 1 = one or more gates failed
|
||||
// 2 = script error (project-dir missing, etc.)
|
||||
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { join, resolve, basename } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const PROJECT_DIR = resolve(process.argv[2] || ".");
|
||||
|
||||
// Thresholds — adjust here, not by interpretation.
|
||||
const ASSET_USAGE_MIN_PCT = 30;
|
||||
// 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.5;
|
||||
|
||||
const ASSET_EXTS = new Set([".jpg", ".jpeg", ".png", ".svg", ".webp", ".gif", ".mp4", ".webm"]);
|
||||
const ASSET_EXCLUDE_PATTERNS = [/favicon/i, /apple-touch-icon/i];
|
||||
const BEAT_DURATION_DRIFT_TOLERANCE_S = 0.5;
|
||||
|
||||
const SHADER_NAMES = [
|
||||
"cross-warp-morph",
|
||||
@@ -56,12 +56,14 @@ async function main() {
|
||||
}
|
||||
|
||||
const results = [];
|
||||
results.push(await checkAssetUsage());
|
||||
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 checkAnimationMapExists());
|
||||
results.push(await checkBeatDurationConsistency());
|
||||
results.push(await checkMp4Exists());
|
||||
results.push(await checkStoryboardArtifactExists());
|
||||
|
||||
printReport(results);
|
||||
|
||||
@@ -71,48 +73,220 @@ async function main() {
|
||||
|
||||
// ─── Checks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function checkAssetUsage() {
|
||||
const captureDir = join(PROJECT_DIR, "capture", "assets");
|
||||
if (!existsSync(captureDir)) {
|
||||
return {
|
||||
name: "Asset usage",
|
||||
status: "INFO",
|
||||
detail: "No capture/assets/ directory — capture may not have run",
|
||||
};
|
||||
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" };
|
||||
}
|
||||
|
||||
const captured = await listAssetFiles(captureDir);
|
||||
const compositionsDir = join(PROJECT_DIR, "compositions");
|
||||
const referencedSet = new Set();
|
||||
|
||||
const filesToScan = [join(PROJECT_DIR, "index.html")];
|
||||
if (existsSync(compositionsDir)) {
|
||||
const compFiles = (await readdir(compositionsDir)).filter((f) => f.endsWith(".html"));
|
||||
for (const f of compFiles) filesToScan.push(join(compositionsDir, f));
|
||||
}
|
||||
|
||||
for (const f of filesToScan) {
|
||||
if (!existsSync(f)) continue;
|
||||
const content = await readFile(f, "utf-8");
|
||||
for (const asset of captured) {
|
||||
if (content.includes(asset.path) || content.includes(basename(asset.path))) {
|
||||
referencedSet.add(asset.path);
|
||||
}
|
||||
// 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 total = captured.length;
|
||||
const used = referencedSet.size;
|
||||
const pct = total > 0 ? Math.round((used * 100) / total) : 0;
|
||||
const pass = pct >= ASSET_USAGE_MIN_PCT;
|
||||
|
||||
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: "Asset usage",
|
||||
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: `${used}/${total} (${pct}%) — target ≥${ASSET_USAGE_MIN_PCT}%`,
|
||||
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
|
||||
: `Unused brand assets are sitting in capture/assets/. The brand isn't visually present at ${pct}%.`,
|
||||
: [
|
||||
...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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,14 +304,33 @@ async function checkShaderTransitionsConsistency() {
|
||||
const storyboard = await readFile(storyboardPath, "utf-8");
|
||||
const index = await readFile(indexPath, "utf-8");
|
||||
|
||||
// Match longest names first so "cross-warp-morph" doesn't double-count as "cross-warp" too.
|
||||
// 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);
|
||||
let sbScratch = storyboard;
|
||||
const seen = new Set();
|
||||
const declared = [];
|
||||
for (const name of sortedNames) {
|
||||
if (sbScratch.includes(name)) {
|
||||
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);
|
||||
sbScratch = sbScratch.split(name).join(""); // strip all matches before next probe
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
if (declared.length === 0) {
|
||||
@@ -148,8 +341,20 @@ async function checkShaderTransitionsConsistency() {
|
||||
};
|
||||
}
|
||||
|
||||
const present = declared.filter((name) => index.includes(name));
|
||||
const missing = declared.filter((name) => !index.includes(name));
|
||||
// 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 {
|
||||
@@ -158,7 +363,7 @@ async function checkShaderTransitionsConsistency() {
|
||||
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.`,
|
||||
: `Missing from build: ${missing.join(", ")}. STORYBOARD.md and index.html disagree — either re-add the transitions or update STORYBOARD.md.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -166,7 +371,11 @@ 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" };
|
||||
return {
|
||||
name: "SFX timestamps",
|
||||
status: "INFO",
|
||||
detail: "STORYBOARD.md or index.html missing",
|
||||
};
|
||||
}
|
||||
|
||||
const storyboard = await readFile(storyboardPath, "utf-8");
|
||||
@@ -175,14 +384,19 @@ async function checkSfxTimestampConsistency() {
|
||||
const sfxRefs = [];
|
||||
for (const line of storyboard.split("\n")) {
|
||||
const fileMatch = line.match(/sfx\/([\w-]+\.mp3)/);
|
||||
const timeMatch = line.match(/\|\s*(\d+(?:\.\d+)?)s?\s*\|/);
|
||||
// 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" };
|
||||
return {
|
||||
name: "SFX timestamps",
|
||||
status: "INFO",
|
||||
detail: "No SFX entries detected in STORYBOARD.md",
|
||||
};
|
||||
}
|
||||
|
||||
const indexSfx = new Map();
|
||||
@@ -190,7 +404,8 @@ async function checkSfxTimestampConsistency() {
|
||||
/<audio[^>]*src=["'](?:[^"']*\/)?sfx\/([\w-]+\.mp3)["'][^>]*?data-start=["']([0-9.]+)["']/g;
|
||||
let m;
|
||||
while ((m = audioRegex.exec(index)) !== null) {
|
||||
indexSfx.set(m[1], parseFloat(m[2]));
|
||||
if (!indexSfx.has(m[1])) indexSfx.set(m[1], []);
|
||||
indexSfx.get(m[1]).push(parseFloat(m[2]));
|
||||
}
|
||||
|
||||
const drifts = [];
|
||||
@@ -200,39 +415,154 @@ async function checkSfxTimestampConsistency() {
|
||||
missing.push(ref.file);
|
||||
continue;
|
||||
}
|
||||
const indexT = indexSfx.get(ref.file);
|
||||
const drift = Math.abs(indexT - ref.storyboardT);
|
||||
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, drift });
|
||||
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 · ${indexSfx.size} in index.html · ${missing.length} missing · ${drifts.length} drifted >${SFX_DRIFT_TOLERANCE_S}s`,
|
||||
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 index=${d.indexT}s drift=${d.drift.toFixed(2)}s`,
|
||||
`DRIFT: ${d.file} storyboard=${d.storyboardT}s closest index=${d.indexT}s drift=${d.drift.toFixed(2)}s`,
|
||||
),
|
||||
].join("\n "),
|
||||
};
|
||||
}
|
||||
|
||||
async function checkAnimationMapExists() {
|
||||
const path = join(PROJECT_DIR, "animation-map.json");
|
||||
if (existsSync(path)) {
|
||||
return { name: "animation-map.json", status: "PASS", detail: "exists" };
|
||||
// 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 start–end 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: "animation-map.json",
|
||||
name: "Beat durations",
|
||||
status: "FAIL",
|
||||
detail: "missing — run `node <repo>/skills/hyperframes/scripts/animation-map.mjs <project-dir>`",
|
||||
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 "),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -261,45 +591,89 @@ async function checkMp4Exists() {
|
||||
};
|
||||
}
|
||||
|
||||
async function checkStoryboardArtifactExists() {
|
||||
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(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listAssetFiles(dir) {
|
||||
async function readBeatCompositions() {
|
||||
const dir = join(PROJECT_DIR, "compositions");
|
||||
if (!existsSync(dir)) return [];
|
||||
const files = await readdir(dir);
|
||||
const beats = files.filter((f) => /^beat-/i.test(f) && f.endsWith(".html"));
|
||||
const out = [];
|
||||
async function walk(d) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(d, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const full = join(d, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await walk(full);
|
||||
for (const f of beats) {
|
||||
const content = await readFile(join(dir, f), "utf-8");
|
||||
out.push({ name: f, content });
|
||||
}
|
||||
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;
|
||||
}
|
||||
const ext = e.name.slice(e.name.lastIndexOf(".")).toLowerCase();
|
||||
if (!ASSET_EXTS.has(ext)) continue;
|
||||
if (ASSET_EXCLUDE_PATTERNS.some((re) => re.test(e.name))) continue;
|
||||
const rel = full.replace(PROJECT_DIR + "/", "");
|
||||
out.push({ path: rel, name: e.name });
|
||||
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]));
|
||||
}
|
||||
}
|
||||
await walk(dir);
|
||||
return out;
|
||||
return positions;
|
||||
}
|
||||
|
||||
// Returns a map of { "beat-1-name": durationInSeconds, ... } from index.html.
|
||||
async function readBeatDurationsFromIndex() {
|
||||
const indexPath = join(PROJECT_DIR, "index.html");
|
||||
if (!existsSync(indexPath)) return {};
|
||||
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;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─── Report ──────────────────────────────────────────────────────────────────
|
||||
@@ -311,9 +685,7 @@ function printReport(results) {
|
||||
console.log("");
|
||||
console.log(`w2h-verify · ${PROJECT_DIR}`);
|
||||
console.log(line);
|
||||
console.log(
|
||||
"Check".padEnd(cols.name) + " │ " + "Status".padEnd(cols.status) + " │ " + "Detail",
|
||||
);
|
||||
console.log("Check".padEnd(cols.name) + " │ " + "Status".padEnd(cols.status) + " │ " + "Detail");
|
||||
console.log(line);
|
||||
|
||||
for (const r of results) {
|
||||
@@ -326,12 +698,13 @@ function printReport(results) {
|
||||
(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" + " ".repeat(cols.name + cols.status + 6 + 4)),
|
||||
r.extra.split("\n").join("\n" + indent),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -348,7 +721,9 @@ function printReport(results) {
|
||||
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(
|
||||
"All gates pass. Paste this report into your final user-facing summary as evidence.",
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user