chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
@@ -0,0 +1,759 @@
#!/usr/bin/env node
// w2h-verify.mjs — verification report for a website-to-video 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-video/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);
});