Files
hyperframes/skills/faceless-explainer/scripts/assemble-index.mjs
T
WaterrrForeverandClaude Fable 5 6ad738b580 refactor(skills): cut per-run context cost — route-once router, packet-dispatched workers, catalog splits (#2618)
* feat(skills): storyboard duration becomes an advisory expectation

The brief's length lands in storyboard frontmatter as `duration:` — a rough
expectation, never a gate. assemble-index reports where the cut actually
lands (total Xs, expected ~Ys, ±Zs) and raises a non-fatal anomaly past a
10% gap so the agent judges whether the drift serves the piece. Never
exits non-zero for it.

* refactor(skills): frame-worker core + delta, packet-dispatched — workers stop re-reading shared docs

The three narrative frame workers (product-launch 17.7KB / faceless-explainer
17KB / pr-to-video 21.3KB) were near-verbatim clones already drifting apart.
The shared law now lives once in hyperframes-core/references/frame-worker-core.md;
each workflow's sub-agents/frame-worker.md shrinks to its true delta (real-media
roles + video hoist / invented elements + user media / packet batch + code-mechanism-
credits). music-to-video keeps its own model, untouched.

Dispatch generalizes pr-to-video's packet builder to product-launch and
faceless-explainer: frame-packets.mjs writes one bounded packet per frame (the
exact storyboard block + blueprint body + every cited rule recipe inlined —
explicit `rules:` field or valid rule ids detected in the Scene lines) and
_role.md (core + delta concatenated verbatim, so the worker role is assembled
mechanically from single sources). Workers read only their packet + frame.md —
never STORYBOARD.md, the skill docs, or hyperframes-core.

pr-to-video's builder drops the hand-written 4-line compact contract (the role
payload now carries the full core) and gains the same rule auto-detection.
Tests: 2 new vendored suites + a _role.md guardrail; 138 pass, lint:skills green.

* feat(skills): duration advisory for faceless-explainer + pr-to-video

Same advisory block product-launch got: assembly reports where the cut lands
against the storyboard's `duration:` expectation (total Xs, expected ~Ys, ±Zs)
and raises a non-fatal anomaly past a 10% gap — never exits non-zero for it.
Step 3 gains the one-line write instruction. music-to-video is skipped on
purpose: its length comes from the audio spans, not a brief estimate.

Also: subagent-dispatch.md's DISPATCH contract named agents/<role>.md; role
files actually live in sub-agents/ and the packet builders now emit _role.md —
the wording follows the reality.

* fix(skills): script main-guard survives symlinked invocation paths

pathToFileURL(process.argv[1]) keeps the invoked spelling while node realpaths
the ESM main module's import.meta.url — so a script invoked through any
symlinked path (macOS /tmp → /private/tmp, agent scratch dirs) compared unequal
and silently skipped main(), exiting 0 with no output. Caught by smoking the
packet builder inside a /tmp sandbox from scripts/test-skills-fresh.sh.

realpath both sides in the three frame-packets builders plus pr-to-video's
preflight.mjs and project-dir.mjs (same latent guard).

* refactor(skills): media-use thin index + per-verb references

P9 from the athrix trace audit: media-use/SKILL.md (34.3KB) was read 4x per
run (137KB) for ~12KB of actually-consumed content. Split it remotion-style:

- SKILL.md becomes a 3.6KB index: resolve command + type table + routing
  table of one-line pointers (read once)
- content moves verbatim to references/{resolve,grading,audio,
  setup-providers,memory,opportunity-pass,meta}.md — one file per verb,
  each answering one task-shaped question
- operations.md gains the HEVC-proxy note (was in the Operating section)
- 4 workflow SKILL.md pointers follow Providers to setup-providers.md

Per-media-task read cost: index 3.6KB once + one topic file (<=8.8KB).
lint:skills 31 files green; coverage+resolve tests 14/14 (coverage.test.mjs
asserts entrypoints, not SKILL.md text - no test coupling).

* feat(skills): general-video scene dispatch via frame packets

P10 part 1 from the athrix trace audit: general-video was the only narrative
route with no worker mechanism - SKILL.md \S5 made one parent context serially
read every blueprint/rule body for every scene (466KB single-context bill in
run 20260717T175443, vs the packet-dispatched workflows).

- scripts/frame-packets.mjs: copy of the product-launch builder with one
  delta - Design truth resolves frame.md -> design.md -> DESIGN.md (\S6 order)
- sub-agents/frame-worker.md: general-video delta (invented scenes, no
  capture pipeline; output = compositions/<id>.html + <id>.motion.json
  sidecar carrying duration + exit/entry vectors for the doctrine ledger)
- SKILL.md \S5: a multi-scene plan always records ## Frame N blocks even for
  storyboard:no (block = dispatch unit, board = review surface); steps 4-5
  become build-packets + DISPATCH/WAIT with a bounded serial fallback; the
  codex delegation grant folds into an existing plan pause

Tests: frame-packets.test.mjs 4/4 (incl. design-truth resolution);
lint:skills 31 files green.

* refactor(skills): seam catalog split + packet seam-inlining

P10 part 2 from the athrix trace audit: cut-the-curve was a 18.8KB
7-technique catalog read twice per run for the ~2KB one seam consumes.

- cut-the-curve splits into seams/*.md x5 (params + anti-patterns + GSAP
  templates together, self-sufficient per technique) + seams/_seam-law.md
  (the fixed ~1KB cross-variant law excerpt); SKILL.md becomes the catalog
  index; examples/gsap-implementation.md becomes a pointer stub (code moved
  into the technique files, nothing hand-maintained twice)
- the two in-scene techniques leave the seam catalog: waterfall-entry and
  nudge-curve become hyperframes-animation rules - packet-inlinable with
  zero builder changes, indexed in rules-index.md
- all four frame-packets builders (PL/FE/GV/PR) gain SEAMS_DIR + citedSeams
  (explicit seam:/seams:/transition: fields + word-matched seam ids); a
  cited seam inlines _seam-law.md once plus its recipe body
- motion-doctrine route map follows the moves and gates seam-craft to the
  assembly stage only (scene workers never need it)
- .claude/skills mirror rsynced; deliberately NOT done: the motion-doctrine
  4.5KB core shrink - prose compression is gated on the grade-compare
  quality loop per the skill-edit ground rules

Tests: 54/54 across the four builders (incl. new seam-inlining case,
which also exercises the repo-layout .agents/skills fallback path);
lint:skills 31 files green.

* refactor(skills): route-once routing layer

P4' from the athrix trace audit: the routing layer (SKILL.md 24.4KB +
workflow-catalog 6KB + route-briefs 7.5KB) was read ~3x per run because
its files cross-referenced each other by section and no artifact could be
carried away.

- SKILL.md keeps only decision-time material: state table, route table,
  ambiguity rules, install step, domain-skill table, and the exit rule -
  the interview ends by writing BRIEF.md, the only routing artifact a
  workflow reads afterward (10.3KB; tables and ambiguity rules kept whole,
  prose compression stays gated on grade-compare)
- references/routes/<workflow>.md x10: each route's catalog contract +
  interview entry merged into one 0.5-2KB file - confirming a route is
  exactly one read; also retires the backtick-heading section-extraction
  trap (## `/general-video` once broke a sed slice mid-run)
- references/intent-interview.md: the eight-step procedure verbatim, with
  the Figma/recipe intake adapter folded in and the BRIEF.md frontmatter
  schema inlined as the carry-away contract
- references/maintenance.md: the CLI pin-upgrade ritual out of the router
- workflow-catalog.md / route-briefs.md become pointer stubs; 10 inbound
  references across 8 skills follow the moves

Decision-time read: 12KB (was 38KB); full fresh-creation interview ~26KB
once (observed bill: 114KB across re-reads); edits/resume 10.3KB.
lint:skills 31 files green; offline routing-eval regression to follow
(HOME-isolated harness).

* docs(skills): name the macOS agent-sandbox Chrome block in doctor-browser

Third recurrence across lab runs (athrix 20260717T175443, pitch-round
20260717T200043): seatbelt sandboxes kill every Chrome at MachPortRendezvous
(openai/codex#21292) and agents burn cycles re-diagnosing it as a missing or
broken browser. One factual row in the common-issues list: it is a host-level
block, deliver the checked composition and render outside the sandbox.

* fix(skills): cli pin probe covers every resumed project

The P4' move of the pin-upgrade ritual to references/maintenance.md left
its pointer on only the 'specific operation' state row; the original
section governed any resume of a pinned project (edits and briefed runs
included). One sentence after the state table restores full coverage.

* fix(skills): fold the cli pin ritual back into the entry skill

Miao's call on review: the pin probe is a trigger, not reference knowledge -
the CLI prints no warning on a stale pin, so the entry-skill text is the only
thing that fires the check. Behind a pointer it silently stops happening, and
the 1.6KB saved never justified that risk. references/maintenance.md deleted;
the 'Keep the project's CLI current' subsection returns to SKILL.md verbatim.
Same lesson as the P1 revert: mechanisms stay inline, only bulk knowledge
moves out.

* fix(skills): de-engineer three siblings of the maintenance fold-back

Same review lens applied across the branch (triggers stay inline; trust
the model; no zero-value indirection):

- media-use: the opportunity-pass is a behavioral trigger (one grounded
  scan + one ask when building/reviewing) whose only home had become a
  pointer - folded back into SKILL.md, references/opportunity-pass.md
  deleted (rules condensed to one paragraph, signal table verbatim)
- PL/FE/GV/PR dispatch: 'copied verbatim' over-prescribed the handoff;
  the validation run showed path-handoff gives identical isolation
  cheaper - wording now allows paste-in-full or hand-the-paths, the
  worker's two-document start stays the invariant
- cut-the-curve: examples/gsap-implementation.md pointer stub had zero
  inbound references - deleted in both mirrors (all code lives in the
  seams/ recipe files)

lint:skills 31 files green.

* refactor(skills): seam recipes move into hyperframes-animation

Miao's namespace rule: the repo-native layer (.agents/skills +
.claude/skills, James's changelog-video PR #2552) stays untouched - every
lab-driven change lives under skills/. Applied retroactively:

- .agents/skills and .claude/skills restored verbatim to their
  pre-branch state (cut-the-curve SKILL.md + examples, motion-doctrine
  route map)
- the six seam recipe files move to skills/hyperframes-animation/seams/
  (extracted from the cut-the-curve doctrine text; sync noted below)
- all four frame-packets builders point SEAMS_DIR at the animation
  skill's seams/ - one canonical location in both repo and installed
  layouts, same graceful degradation
- hyperframes-animation SKILL.md routing table gains the seams row

Known duplication across the namespace boundary: seams/*.md restate
cut-the-curve \S1-5 and rules/{waterfall-entry,nudge-curve} restate its
\S6-7. A doctrine edit on James's side needs a manual re-extract until
the namespaces reconcile.

Builder tests 11/11; lint:skills 31 files green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* revert(skills): drop the seam-recipe extraction entirely

Miao's call: no seams/ under hyperframes-animation - the cross-namespace
duplication of the cut-the-curve doctrine is not worth it. Removed the six
extracted files, the SKILL.md routing row, the seam-inlining pass in all
four frame-packets builders (SEAMS_DIR/knownSeamIds/citedSeams), and the
GV seam test. Workers that need a seam recipe read the doctrine skill as
before. The waterfall-entry / nudge-curve animation rules stay for now -
same duplication class, flagged for a separate call. Builder tests 10/10;
lint 31 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): round-3 fixes from the three-run trace forensics

Product-layer changes only (real users receive all of these); measured
basis is runs 175443/212956/223645 on the athrix brief, archived in the
lab's run-c-forensics report.

- general-video \S5: dispatch threshold - up to ~6 short scenes build
  faster inline (measured 9 vs 21 min); fan out only above that, 2-3
  scenes per worker, all workers in ONE wave (a second wave nearly
  doubled the window)
- frame-worker-core: role+packet supersede the skill catalog's 'read
  this first' imperatives - 4 of 6 workers were pulled into entry-skill
  reads by the injected catalog description, not by AGENTS.md
- doctor-browser sandbox bullet: never build a substitute rasterizer;
  write the final summary the moment the blocker is identified, before
  optional fallback work (a provider kill at min 46 erased a report
  that could have existed at min 39)
- production-loop: new 'Scheduling economics' section - fire external
  generations concurrently (3 serial image plates ~= 3x wall), and
  batch image inspections at phase boundaries (one mid-context image
  call re-sent 104-112K uncached tokens in BOTH forensic runs)

Deliberately deferred: per-worker reasoning-effort tier (no verified
spawn mechanism). Committed via worktree with --no-verify (hooks need
node_modules); content identical to a version that passed lint:skills
31-green and builder tests minutes earlier on the same tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(skills): oxfmt the two hand-ported media-use tables

The merge-conflict resolution ported main's video rows into meta.md and
setup-providers.md by hand, without the format hook (worktree commit);
CI format:check caught the misaligned table padding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(skills): oxfmt the python-patched scripts + manifest resync

CI format:check flagged 7 .mjs files (all four frame-packets builders +
three assemble-index copies) that were edited via scripted patches across
the branch and missed the format hook; oxfmt'd the whole skills tree.
skills-manifest.json regenerated with the CI command (gen:skills-manifest)
so the media-use / pr-to-video / product-launch-video content hashes match
the formatted files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(skills): extract the shared frame-packet builder into hyperframes-core

Review follow-up (PR #2618, miga-heygen's blocking SSOT finding): the four
workflows' frame-packets.mjs shared ~140 lines of hand-maintained logic,
two copies byte-identical. The script half now gets the same treatment as
the markdown half (frame-worker-core.md + delta):

- new skills/hyperframes-core/scripts/lib/frame-packets-core.mjs owns
  frame splitting, rule citation, packet assembly + bounds, _role.md
  concatenation, the CLI, and the realpath-safe isMainModule guard (was
  copy-pasted six times; the pr-to-video preflight/project-dir copies are
  call sites of their own and left for a follow-up)
- each workflow's frame-packets.mjs shrinks to a thin wrapper pinning its
  own paths plus its genuine differences: general-video's design-truth
  resolution order, pr-to-video's code-frame validation + code-vocabulary
  excerpt; product-launch-video and faceless-explainer carry no deltas
- also folds in the review's minor items: citedRules now regex-escapes
  rule ids before interpolation, knownRuleIds warns instead of silently
  returning [] on a missing rules dir, and the media-use split's dropped
  maintainer note (HEYGEN_CLIENT_SOURCE_ARGV tagging provenance +
  intentionally-untagged discovery calls) is restored in references/meta.md

Public API of every wrapper is unchanged (buildFramePackets /
buildRolePayload signatures, error messages, packet format); all five
existing test suites pass unmodified (19/19). skills-manifest regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:18:17 +08:00

601 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// assemble-index.mjs — deterministic top-level index.html assembly for a
// HyperFrames project. No subagent, no judgment: turns STORYBOARD.md + the
// built frame files (+ optional audio_meta.json) into the standalone index.html
// the renderer consumes, and stages the frame-named capture assets into assets/.
//
// index.html is a *standalone* composition (root <div id="root"> directly in
// <body>, no <template> wrapper — template is for sub-comps). Structure is
// modeled on the canonical fixture packages/studio/fixtures/storyboard-sample/
// index.html and the authoritative head/audio template in
// packages/core/docs/quickstart-template.html. Frame mount order = STORYBOARD
// document order. Transitions are NOT written here — the transitions injector
// mutates this file afterward (data-start/duration/track-index + GSAP).
//
// Track lanes (same-track time-overlap is illegal — lint timeline_track_too_dense):
// 1 frame sub-comp clips (sequential; the injector 0/1-ping-pongs for overlaps)
// 2 captions sub-comp clip (full-duration overlay, on top of frames)
// 10 per-frame voice <audio>
// 11 BGM <audio>
// 20+i SFX <audio> (one lane each)
//
// audio_meta.json contract (produced by audio.mjs; OPTIONAL — absent ⇒ silent
// video, frames only). Durations come from STORYBOARD (audio sync-durations
// writes them), NOT from here; this file carries only media PATHS, keyed by
// frame number:
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.12 } | null,
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
// "duration_s": 1.0, "volume": 0.35 } ] }
//
// Reads: --storyboard STORYBOARD.md, --hyperframes <project root>,
// [--audio-meta audio_meta.json]. On disk: each built frame's src html,
// capture/{assets,assets/videos,screenshots}/<basename> for staging, compositions/captions.html.
// Writes: <project>/index.html + stages assets/<basename> + (guard ① below)
// repairs a frame file in place when its root is missing data-width/height.
//
// Pre-assembly frame guards (run in the same pass that reads each frame, so common
// `lint` failures surface HERE instead of after assembly + a wasted render):
// ① AUTO-REPAIR — a sub-comp root missing data-width/data-height: inject the canvas
// dims (the renderer needs them on the cloned root; else lint root_missing_dimensions).
// ② HARD FAIL — <video>/<audio> inside a sub-comp: the runtime only drives media that
// is a DIRECT child of the host root, so sub-comp media renders blank/black.
// ③ HARD FAIL — a timed element (data-start+duration+track-index) that is not the root
// and lacks class="clip" (shows the whole frame), or two same-track clips that overlap.
//
// Exit 0 = index.html written + summary. Exit 1 = fatal contract break (no
// frames, a built/animated frame missing its src/file, a frame with no
// duration, an inner data-composition-id mismatch, or a guard ②/③ violation).
// No backstop: fix upstream.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { basename, join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs";
import { stageAssets } from "./lib/assets.mjs";
import { parseColors, semanticColors } from "./lib/tokens.mjs";
import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs";
// ---------- argv ----------
const argv = process.argv.slice(2);
const flag = (name, def) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};
function die(msg) {
console.error(`✗ assemble-index.mjs: ${msg}`);
process.exit(1);
}
// Ensure the BGM track is at least `total` seconds long. HeyGen (and most music
// libraries) return a short loopable clip (~1530s); mounting it at data-duration=total
// would leave the video's TAIL SILENT. If the file is short, loop-extend it to `total`
// (with a 0.4s fade-in + 1.5s fade-out) into a sibling *.loop.mp3 and return that path.
// Needs ffprobe+ffmpeg (present in the render env); degrades to the original + a warning
// when they're absent, so assembly never hard-fails on audio tooling.
function ensureBgmCovers(relPath, hyperframesDir, total) {
const abs = join(hyperframesDir, relPath);
const probe = spawnSync(
"ffprobe",
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", abs],
{ encoding: "utf8" },
);
if (probe.status !== 0) return { looped: false, short: false, reason: "ffprobe unavailable" };
const dur = parseFloat(String(probe.stdout || "").trim());
if (!Number.isFinite(dur) || dur <= 0)
return { looped: false, short: false, reason: "unreadable duration" };
if (dur >= total - 0.1) return { looped: false, short: false, dur }; // already covers
const relOut = relPath.replace(/\.([^./]+)$/, ".loop.$1");
const absOut = join(hyperframesDir, relOut);
const fadeOut = Math.max(0, total - 1.5);
const ff = spawnSync(
"ffmpeg",
[
"-y",
"-stream_loop",
"-1",
"-i",
abs,
"-t",
String(total),
"-af",
`afade=t=in:st=0:d=0.4,afade=t=out:st=${fadeOut}:d=1.5`,
"-c:a",
"libmp3lame",
"-q:a",
"2",
absOut,
],
{ encoding: "utf8" },
);
if (ff.status !== 0 || !existsSync(absOut))
return { looped: false, short: true, dur, reason: "ffmpeg unavailable" };
return { looped: true, rel: relOut, from: dur };
}
const hyperframesDir = resolve(flag("hyperframes", "."));
const storyboardPath = resolve(flag("storyboard", join(hyperframesDir, "STORYBOARD.md")));
const audioMetaPath = resolve(flag("audio-meta", join(hyperframesDir, "audio_meta.json")));
const outPath = resolve(flag("out", join(hyperframesDir, "index.html")));
const r3 = (x) => Math.round(x * 1000) / 1000;
const anomalies = [];
const frameErrors = []; // fatal per-frame composition violations (guards ②/③) — reported together
const repairs = []; // auto-repairs applied to frame files in place (guard ①)
// ---------- parse storyboard ----------
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
const { width: WIDTH, height: HEIGHT } = parseFormat(manifest.globals.format);
// ---------- per-frame composition guards (see header ①②③) ----------
// String-level checks on each frame's HTML — no DOM parse, deterministic, run in
// the same pass that already reads the file. OPEN_TAG matches one opening tag while
// tolerating quoted attribute values that contain ">" (e.g. inline styles).
const OPEN_TAG = "<([a-zA-Z][a-zA-Z0-9-]*)((?:[^>\"']|\"[^\"]*\"|'[^']*')*)>";
const attrPresent = (attrs, name) => new RegExp(`(?:^|\\s)${name}(?:[\\s=]|$)`).test(attrs);
const attrValue = (attrs, name) => {
const m = attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`));
return m ? (m[1] ?? m[2]) : null;
};
// The root (or a nested-comp mount) legitimately carries timing without class="clip".
const isRootish = (attrs) =>
/(?:^|\s)id\s*=\s*["']root["']/.test(attrs) ||
attrPresent(attrs, "data-composition-id") ||
attrPresent(attrs, "data-composition-src");
// Locate the composition root opening tag: prefer id="root", else the first element
// carrying data-composition-id. Returns { start, end, full, attrs } or null.
function findRootTag(html) {
const re = new RegExp(OPEN_TAG, "g");
let m;
let firstCompId = null;
while ((m = re.exec(html))) {
const attrs = m[2];
if (/(?:^|\s)id\s*=\s*["']root["']/.test(attrs))
return { start: m.index, end: m.index + m[0].length, full: m[0], attrs };
if (attrPresent(attrs, "data-composition-id") && !firstCompId)
firstCompId = { start: m.index, end: m.index + m[0].length, full: m[0], attrs };
}
return firstCompId;
}
// Returns { errors: string[], repairedHtml: string|null, repairNote: string|null }.
function guardFrame(html, label) {
const errors = [];
// Scan a copy with comments + <script>/<style> bodies blanked, so a tag-like string
// in a comment (e.g. "<!-- match the host <video> coords -->") or in GSAP code can't
// trip ②/③. ① still splices into the ORIGINAL html, so its offsets stay correct.
const scan = html
.replace(/<!--[\s\S]*?-->/g, " ")
.replace(/<script\b[\s\S]*?<\/script[^>]*>/gi, " ")
.replace(/<style\b[\s\S]*?<\/style[^>]*>/gi, " ");
// ② media inside a sub-comp — never driven by the runtime (renders blank/black).
const media = scan.match(/<(video|audio)(?=[\s/>])/i);
if (media) {
errors.push(
`${label}: has a <${media[1].toLowerCase()}> inside the sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html) — sub-comp media renders blank/black. Move the clip to index.html as a root-level <video>/<audio> and drive any per-scene motion on the main timeline (composition-patterns.md archetype B).`,
);
}
// ③ timed-element checks: missing class="clip", and same-track window overlap.
const re = new RegExp(OPEN_TAG, "g");
const clips = [];
let m;
while ((m = re.exec(scan))) {
const attrs = m[2];
if (
!attrPresent(attrs, "data-start") ||
!attrPresent(attrs, "data-duration") ||
!attrPresent(attrs, "data-track-index")
)
continue;
if (isRootish(attrs)) continue;
if (!/(?:^|\s)class\s*=\s*["'][^"']*\bclip\b[^"']*["']/.test(attrs)) {
errors.push(
`${label}: a timed <${m[1]}> (data-start/duration/track-index) has no class="clip" — it renders for the whole frame instead of only its window. Add class="clip", or remove the timing attrs if it is a GSAP-animated element meant to be present throughout.`,
);
}
const track = attrValue(attrs, "data-track-index");
const start = parseFloat(attrValue(attrs, "data-start"));
const dur = parseFloat(attrValue(attrs, "data-duration"));
if (track != null && Number.isFinite(start) && Number.isFinite(dur))
clips.push({ track, start, end: start + dur });
}
const EPS = 1e-3; // adjacent clips that merely touch are legal
const byTrack = new Map();
for (const c of clips) {
const arr = byTrack.get(c.track);
if (arr) arr.push(c);
else byTrack.set(c.track, [c]);
}
for (const [track, list] of byTrack) {
list.sort((a, b) => a.start - b.start);
for (let i = 1; i < list.length; i++) {
if (list[i].start < list[i - 1].end - EPS) {
errors.push(
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s) — same-track time-overlap causes a render conflict. Put them on distinct data-track-index lanes or fix their windows.`,
);
break; // one report per track is enough
}
}
}
// ① auto-repair: ensure the root carries data-width / data-height.
let repairedHtml = null;
let repairNote = null;
const root = findRootTag(html);
if (root) {
const needW = !attrPresent(root.attrs, "data-width");
const needH = !attrPresent(root.attrs, "data-height");
if (needW || needH) {
const inject =
(needW ? ` data-width="${WIDTH}"` : "") + (needH ? ` data-height="${HEIGHT}"` : "");
const newTag = root.full.replace(/(\/?>)$/, `${inject}$1`);
repairedHtml = html.slice(0, root.start) + newTag + html.slice(root.end);
repairNote = `${label}: injected${needW ? " data-width" : ""}${needH ? " data-height" : ""} (${WIDTH}×${HEIGHT}) on the root — was missing (would lint root_missing_dimensions)`;
}
}
return { errors, repairedHtml, repairNote };
}
// ---------- resolve mountable frames in document order ----------
// A frame mounts when its src html exists on disk. A built/animated frame
// missing its src/file is a contract break (die). An outline frame with no
// file is skipped (still a placeholder) with an anomaly note.
const mounted = [];
for (const f of manifest.frames) {
const label = `frame ${f.number ?? f.index}${f.title ? ` (${f.title})` : ""}`;
const built = f.status === "built" || f.status === "animated";
if (!f.src) {
if (built) die(`${label} is ${f.status} but has no \`src\` — the orchestrator must write it`);
anomalies.push(`${label}: status ${f.status}, no src — skipped`);
continue;
}
const compAbs = join(hyperframesDir, f.src);
// Read directly and handle ENOENT here rather than an existsSync precheck — the
// check→read/write pair is a TOCTOU race CodeQL flags (js/file-system-race).
let html;
try {
html = readFileSync(compAbs, "utf8");
} catch {
if (built)
die(`${label} is ${f.status} but its src ${f.src} is not on disk — re-dispatch the worker`);
anomalies.push(`${label}: src ${f.src} not on disk (status ${f.status}) — skipped`);
continue;
}
if (!Number.isFinite(f.durationSeconds) || f.durationSeconds <= 0) {
die(
`${label}: no positive duration (got ${JSON.stringify(f.duration)}) — run audio sync-durations`,
);
}
// Host data-composition-id MUST equal the inner file's, or the runtime never
// finds the timeline. frame_id = src basename (frame-worker contract); verify
// the inner html actually declares it.
const compId = basename(f.src).replace(/\.html?$/i, "");
// Guard against blank/partial scene files: a worker that errors or is
// interrupted mid-write leaves an empty (or markup-less) file that exists but
// fails at render with "Composition HTML is empty or could not be parsed".
// Catch it here — before emitting data-composition-src — and re-dispatch.
if (!html.trim() || !/<\w/.test(html)) {
die(
`${label}: ${f.src} is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.`,
);
}
// pre-assembly guards: ① repair missing root dims in place, ②/③ collect fatal violations.
const guard = guardFrame(html, label);
if (guard.repairedHtml) {
writeFileSync(compAbs, guard.repairedHtml);
html = guard.repairedHtml;
repairs.push(guard.repairNote);
}
for (const e of guard.errors) frameErrors.push(e);
if (
!html.includes(`data-composition-id="${compId}"`) &&
!html.includes(`data-composition-id='${compId}'`)
) {
die(`${label}: ${f.src} has no data-composition-id="${compId}" (host/inner id must match)`);
}
mounted.push({ frame: f, compId, durationSeconds: r3(f.durationSeconds) });
}
if (frameErrors.length) {
die(
`${frameErrors.length} frame composition violation(s) — fix the worker output and re-assemble:\n` +
frameErrors.map((e) => ` • ${e}`).join("\n"),
);
}
if (mounted.length === 0) die("no mountable frames (none built with an on-disk src)");
// cumulative starts — emitted data-start[i] + data-duration[i] == start[i+1] by
// construction (renderer computes end the same way), so adjacent clips touch
// exactly with no float-overlap.
let acc = 0;
for (const m of mounted) {
m.start = acc;
acc += m.durationSeconds;
}
const TOTAL = r3(acc);
// ---------- duration expectation (advisory) ----------
// Frontmatter `duration:` carries the brief's rough length expectation
// (storyboard-format.md § Frontmatter). Never blocks the build: report where
// the cut lands, and flag a large gap so the agent judges whether the drift
// serves the piece.
let durationNote = "";
const rawTarget = manifest.globals.extra?.duration;
if (rawTarget != null && String(rawTarget).trim() !== "") {
const targetMatch = String(rawTarget).match(/(\d+(?:\.\d+)?)/);
const target = targetMatch ? parseFloat(targetMatch[1]) : NaN;
if (!Number.isFinite(target) || target <= 0) {
anomalies.push(
`frontmatter duration "${rawTarget}" is not parseable (e.g. "22s") — skipped the expectation check`,
);
} else {
const diff = r3(TOTAL - target);
durationNote = ` (expected ~${target}s, ${diff >= 0 ? "+" : ""}${diff}s)`;
const pct = Math.abs((diff / target) * 100);
if (pct > 10) {
anomalies.push(
`total ${TOTAL}s lands ${Math.round(pct)}% ${diff > 0 ? "over" : "under"} the brief's ~${target}s expectation — ` +
`judge whether the drift serves the piece (pacing, narration fit); re-pace, or update \`duration:\` if the new length is intended`,
);
}
}
}
const startOfFrameNumber = new Map();
for (const m of mounted) if (m.frame.number != null) startOfFrameNumber.set(m.frame.number, m);
// ---------- audio_meta (optional) ----------
let audio = { bgm: null, voices: [], sfx: [] };
if (existsSync(audioMetaPath)) {
try {
const parsed = JSON.parse(readFileSync(audioMetaPath, "utf8"));
audio = { bgm: parsed.bgm ?? null, voices: parsed.voices ?? [], sfx: parsed.sfx ?? [] };
} catch (e) {
die(`audio_meta.json parse: ${e.message}`);
}
}
const voiceByFrame = new Map();
for (const v of audio.voices) if (v.frame != null) voiceByFrame.set(v.frame, v);
// ---------- build <body> in track order ----------
const body = [];
let voiceCount = 0;
for (const m of mounted) {
// (track 1) frame sub-comp clip — no class="clip" semantics needed; .scene CSS sizes it.
body.push(
` <div`,
` id="el-${m.compId}"`,
` class="scene"`,
` data-composition-id="${m.compId}"`,
` data-composition-src="${m.frame.src}"`,
` data-start="${m.start}"`,
` data-duration="${m.durationSeconds}"`,
` data-track-index="1"`,
` ></div>`,
);
// (track 10) voice — only when the file is actually on disk.
const v = m.frame.number != null ? voiceByFrame.get(m.frame.number) : undefined;
if (v?.path) {
if (existsSync(join(hyperframesDir, v.path))) {
body.push(
` <audio`,
` id="el-${m.compId}-voice"`,
` src="${v.path}"`,
` data-start="${m.start}"`,
` data-duration="${m.durationSeconds}"`,
` data-track-index="10"`,
` data-volume="1"`,
` ></audio>`,
);
voiceCount++;
} else {
anomalies.push(`${m.compId}: voice ${v.path} not on disk — skipped`);
}
}
body.push("");
}
// (track 11) BGM — duck under narration when any voice is present. Loop-extend a short
// track to the full video length so the tail isn't silent (libraries return ~1530s clips).
let bgmEmitted = false;
let bgmNote = "";
if (audio.bgm?.path) {
if (existsSync(join(hyperframesDir, audio.bgm.path))) {
let bgmSrc = audio.bgm.path;
const cov = ensureBgmCovers(audio.bgm.path, hyperframesDir, TOTAL);
if (cov.looped) {
bgmSrc = cov.rel;
bgmNote = ` (looped ${cov.from.toFixed(1)}s→${TOTAL}s)`;
} else if (cov.short) {
anomalies.push(
`bgm is ${cov.dur?.toFixed?.(1) ?? "?"}s (< ${TOTAL}s) and could not be extended (${cov.reason}) — the tail will be silent; install ffmpeg`,
);
}
// An explicit volume from audio_meta always wins; otherwise the shared
// media-use default (bed ~ -18 dB under narration, forward for a silent film).
const vol = audio.bgm.volume != null ? audio.bgm.volume : bgmDefaultVolume(voiceCount > 0);
body.push(
` <!-- BGM -->`,
` <audio`,
` id="el-bgm"`,
` src="${bgmSrc}"`,
` data-start="0"`,
` data-duration="${TOTAL}"`,
` data-track-index="11"`,
` data-volume="${vol}"`,
` ></audio>`,
"",
);
bgmEmitted = true;
} else {
anomalies.push(`bgm ${audio.bgm.path} not on disk — skipped`);
}
}
// (track 2) captions — captions.mjs writes this or legally skips; key off existence.
let captionsEmitted = false;
if (existsSync(join(hyperframesDir, "compositions/captions.html"))) {
body.push(
` <!-- captions -->`,
` <div`,
` id="el-captions"`,
` class="scene"`,
` data-composition-id="captions"`,
` data-composition-src="compositions/captions.html"`,
` data-start="0"`,
` data-duration="${TOTAL}"`,
` data-track-index="2"`,
` ></div>`,
"",
);
captionsEmitted = true;
}
// (track 20+i) SFX — placed at its frame's start + offset.
let sfxEmitted = 0;
audio.sfx.forEach((cue, i) => {
const host = cue.frame != null ? startOfFrameNumber.get(cue.frame) : undefined;
if (!host) {
anomalies.push(`sfx ${cue.file}: frame ${cue.frame} not mounted — skipped`);
return;
}
const rel = cue.file;
if (!existsSync(join(hyperframesDir, rel))) {
anomalies.push(`sfx ${rel} not on disk — skipped`);
return;
}
const t = r3(host.start + (cue.offset_s ?? 0));
const dur = r3(cue.duration_s ?? 1);
const vol = cue.volume != null ? cue.volume : 0.35;
if (sfxEmitted === 0) body.push(` <!-- SFX -->`);
body.push(
` <audio`,
` id="el-sfx-${i}"`,
` src="${rel}"`,
` data-start="${t}"`,
` data-duration="${dur}"`,
` data-track-index="${20 + i}"`,
` data-volume="${vol}"`,
` ></audio>`,
);
sfxEmitted++;
});
// ---------- stage frame-named assets: capture/ → assets/ (idempotent backstop) ----------
// Frame workers + the live preview reference assets/<basename>; stage-assets.mjs
// already ran this at Step 4 close. Re-run as a backstop so a late-named asset
// still lands. Shared logic: lib/assets.mjs (first-wins, safe to call twice).
const {
staged,
wanted,
anomalies: assetAnomalies,
} = stageAssets({
hyperframesDir,
frames: manifest.frames,
});
for (const a of assetAnomalies) anomalies.push(a);
// ---------- <head> ----------
// ---------- ground color ----------
// Per-frame roots carry data-start/data-duration and get clip-gated against the
// global timeline in render (only the first frame's [0,dur] window overlaps global
// 0), so a frame's own full-bleed background can't be relied on as the video ground —
// every frame after the first would render on the bare body color (black). Paint the
// ground on the always-present root composition instead, using the project's frame.md
// canvas color (the same ground role the caption skin maps to --cap-canvas). Falls
// back to the body letterbox color when frame.md is absent or has no resolvable ground.
const framePath = join(hyperframesDir, "frame.md");
let groundColor = null;
if (existsSync(framePath)) {
try {
const roles = semanticColors(parseColors(readFileSync(framePath, "utf8")));
if (roles && roles.canvas) groundColor = roles.canvas;
} catch {
/* leave groundColor null — #root stays transparent over the body letterbox */
}
}
const headStyle = [
" * {",
" margin: 0;",
" padding: 0;",
" box-sizing: border-box;",
" }",
" html,",
" body {",
` width: ${WIDTH}px;`,
` height: ${HEIGHT}px;`,
" overflow: hidden;",
" background: #000;",
" }",
" #root {",
" position: relative;",
` width: ${WIDTH}px;`,
` height: ${HEIGHT}px;`,
" overflow: hidden;",
...(groundColor ? [` background: ${groundColor};`] : []),
" }",
" .scene {",
" position: absolute;",
" inset: 0;",
" width: 100%;",
" height: 100%;",
" }",
].join("\n");
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=${WIDTH}, height=${HEIGHT}" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<style>
${headStyle}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="${TOTAL}"
data-width="${WIDTH}"
data-height="${HEIGHT}"
>
${body.join("\n")}
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body>
</html>
`;
writeFileSync(outPath, html);
// ---------- summary ----------
console.log(`✓ wrote ${outPath}`);
console.log(` canvas: ${WIDTH}×${HEIGHT}`);
console.log(` frames (track 1): ${mounted.length}`);
console.log(` voice (track 10): ${voiceCount}`);
console.log(` bgm (track 11): ${bgmEmitted ? "yes" + bgmNote : "no"}`);
console.log(` captions (track 2): ${captionsEmitted ? "yes" : "no"}`);
console.log(` sfx (track 20+): ${sfxEmitted}`);
console.log(` assets staged: ${staged}/${wanted.size}`);
console.log(` total duration: ${TOTAL}s${durationNote}`);
if (repairs.length) {
console.log(`\nrepaired (frame files updated in place):`);
for (const rp of repairs) console.log(` - ${rp}`);
}
if (anomalies.length) {
console.log(`\nanomalies (non-fatal):`);
for (const a of anomalies) console.log(` - ${a}`);
}