diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts
index 3c6039f5b..9c76ab03a 100644
--- a/packages/core/src/lint/rules/composition.ts
+++ b/packages/core/src/lint/rules/composition.ts
@@ -37,6 +37,32 @@ function isCompositionRootOrMount(rawTag: string): boolean {
);
}
+// Top-level CSS selectors (comma-split) in a stylesheet, skipping at-rule headers
+// (@media/@keyframes/...) and keyframe stops. Heuristic — the lint layer has no
+// full CSS parser, and rules elsewhere in this file scan CSS the same way.
+function extractCssSelectors(css: string): string[] {
+ const out: string[] = [];
+ const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
+ const ruleHeader = /([^{}]+)\{/g;
+ let m: RegExpExecArray | null;
+ while ((m = ruleHeader.exec(noComments)) !== null) {
+ const header = (m[1] ?? "").trim();
+ if (!header || header.startsWith("@")) continue;
+ for (const sel of header.split(",")) {
+ const s = sel.trim();
+ if (s) out.push(s);
+ }
+ }
+ return out;
+}
+
+// Class tokens in a selector's leftmost compound (before the first descendant /
+// child / sibling combinator). `.frame .title` → ["frame"]; `.a.b > .c` → ["a","b"].
+function leftmostCompoundClasses(selector: string): string[] {
+ const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
+ return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
+}
+
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// invalid_capture_path — catches ../capture/ in src/href attributes and scripts.
// Sub-compositions live in compositions/ but are served relative to the project
@@ -576,4 +602,50 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
}
return findings;
},
+
+ // subcomposition_root_styled_by_class
+ // A sub-composition's
-
+
@@ -83,23 +85,23 @@ Contrast with **standalone** compositions, which put the root directly in `
- ...
+ ...
-
+
- ...
+ ...
```
@@ -133,6 +135,36 @@ Contrast with **standalone** compositions, which put the root directly in `` for every mismatched slot, waits 45s per scene, then captures static initial-state frames (so the video is full-length but no animation plays).
+### Pitfall 3 — Styling the root by a class instead of `#root`
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Why this happens:** when sub-compositions are inlined into one composited render, the compiler **scopes each file's CSS to its own `data-composition-id`** so scenes can't leak styles into each other — every rule `S` becomes `[data-composition-id="
"] S` (a _descendant_ selector). A rule whose leftmost selector is the **root's own class** (`.frame`) therefore becomes `[data-composition-id=""] .frame`, which cannot match the root (the root _is_ the scoped element, not a descendant of it), so **every `.frame…` rule silently drops**. `#root` is special-cased by the scoper and keeps matching the root; plain descendant selectors (`.title`) match normally. The per-scene class namespace is also just redundant — the `data-composition-id` scope already isolates each scene's styles.
+
+**Symptom:** _identical_ to Pitfall 1 — tiny unstyled text in the top-left, images at natural size, inline styles (e.g. a card background) the only thing surviving. The trap: this passes `lint`/`validate`/`inspect` (they evaluate the file in isolation, unscoped) **and looks perfect in `preview`** (Studio mounts each scene in its own iframe, also unscoped) — it only breaks in the composited MP4 render. Lint rule `subcomposition_root_styled_by_class` flags it; the registry blocks (e.g. `apple-money-count`) model the `#root` pattern.
+
### Verification checklist before render
```bash
@@ -145,9 +177,14 @@ grep -n "
+
+
+
+${body.join("\n")}
+
+
+
+
+
+`;
+writeFileSync(outPath, html);
+
+console.log(`✓ wrote ${outPath}`);
+console.log(` canvas: ${WIDTH}×${HEIGHT}`);
+console.log(` frames (track 1): ${mounted.length}`);
+console.log(` bgm (track 11): ${bgmEmitted ? bgmRel : "MISSING"}`);
+console.log(` vo (track 10): ${voiceCount}`);
+console.log(` total duration: ${TOTAL}s` + (audioDur != null ? ` (audio ${audioDur}s)` : ""));
+if (anomalies.length) {
+ console.log(`\nanomalies (non-fatal):`);
+ for (const a of anomalies) console.log(` - ${a}`);
+}
diff --git a/skills/music-to-video/scripts/lib/storyboard.mjs b/skills/music-to-video/scripts/lib/storyboard.mjs
new file mode 100644
index 000000000..7c821dcba
--- /dev/null
+++ b/skills/music-to-video/scripts/lib/storyboard.mjs
@@ -0,0 +1,249 @@
+// storyboard.mjs — vendored lenient parser for STORYBOARD.md.
+//
+// Faithful plain-JS port of @hyperframes/core/storyboard
+// (packages/core/src/storyboard/parseStoryboard.ts). Vendored because skills
+// ship standalone: installed via `npx skills add`, a skill's scripts can't reach
+// the monorepo's core package, and the core export points at .ts source that
+// `node` (which runs these scripts) can't load. CANONICAL contract = the core
+// parser + skills/hyperframes-core/references/storyboard-format.md; keep this in
+// lockstep. Behavior: never throws, accepts freeform narrative, recognizes
+// Frame/Beat/Scene headings at H2/H3, preserves unknown keys verbatim under
+// `extra` (keys lowercased). Pure node — no deps.
+
+export const FRAME_STATUSES = ["outline", "built", "animated"];
+export const DEFAULT_FRAME_STATUS = "outline";
+
+// Detection-only frame heading (ends at the keyword); ReDoS-hardened — keep as-is.
+const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
+const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/;
+const HEADING_LEVEL_RE = /^(#{1,6})\s+/;
+const META_RE = /^\s*[-*]\s+([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/;
+const LEADING_INT_RE = /^(\d+)/;
+const DURATION_NUM_RE = /(\d+(?:\.\d+)?)/;
+const TRANSITION_KEYS = new Set(["transition_in", "transitionin", "transition"]);
+const SCENE_KEYS = new Set(["scene", "description", "summary", "caption"]);
+export const VOICEOVER_ALIASES = ["voiceover", "vo", "voice_over", "narration"];
+const VOICEOVER_KEYS = new Set(VOICEOVER_ALIASES);
+
+export function parseStoryboard(source) {
+ const warnings = [];
+ const { globals, bodyStartLine, body } = parseFrontmatter(source, warnings);
+ const frames = parseFrames(body, bodyStartLine, warnings);
+ return { globals, frames, warnings };
+}
+
+function emptyGlobals() {
+ return { extra: {} };
+}
+
+function isFrameStatus(value) {
+ return FRAME_STATUSES.includes(value);
+}
+
+// ── Frontmatter ─────────────────────────────────────────────────────────────
+function findFrontmatterRange(lines, warnings) {
+ let start = 0;
+ while (start < lines.length && (lines[start] ?? "").trim() === "") start++;
+ if ((lines[start] ?? "").trim() !== "---") return null;
+ for (let i = start + 1; i < lines.length; i++) {
+ if ((lines[i] ?? "").trim() === "---") return { start, end: i };
+ }
+ warnings.push({
+ message: "Frontmatter opening '---' has no closing '---'; treating whole file as body.",
+ line: start + 1,
+ });
+ return null;
+}
+
+function parseFrontmatterEntries(lines, start, end, warnings) {
+ const globals = emptyGlobals();
+ for (let i = start + 1; i < end; i++) {
+ const raw = lines[i] ?? "";
+ if (raw.trim() === "") continue;
+ const colon = raw.indexOf(":");
+ if (colon === -1) {
+ warnings.push({
+ message: `Ignored non key:value frontmatter line: "${raw.trim()}"`,
+ line: i + 1,
+ });
+ continue;
+ }
+ const key = raw.slice(0, colon).trim().toLowerCase();
+ assignGlobal(globals, key, stripQuotes(raw.slice(colon + 1).trim()));
+ }
+ return globals;
+}
+
+function parseFrontmatter(source, warnings) {
+ const lines = source.split(/\r?\n/);
+ const range = findFrontmatterRange(lines, warnings);
+ if (!range) return { globals: emptyGlobals(), bodyStartLine: 1, body: source };
+ const globals = parseFrontmatterEntries(lines, range.start, range.end, warnings);
+ const body = lines.slice(range.end + 1).join("\n");
+ return { globals, bodyStartLine: range.end + 2, body };
+}
+
+function assignGlobal(globals, key, value) {
+ switch (key) {
+ case "format":
+ globals.format = value;
+ break;
+ case "message":
+ globals.message = value;
+ break;
+ case "arc":
+ globals.arc = value;
+ break;
+ case "audience":
+ globals.audience = value;
+ break;
+ default:
+ globals.extra[key] = value;
+ }
+}
+
+// ── Frames ──────────────────────────────────────────────────────────────────
+function openFrameSection(line, headingLine) {
+ const match = FRAME_HEADING_RE.exec(line);
+ if (!match) return null;
+ const headingText = line.slice(match[0].length).replace(FRAME_TITLE_SEP_RE, "").trim();
+ return { headingText, headingLine, level: (match[1] ?? "##").length, lines: [] };
+}
+
+function endsFrameSection(line, current) {
+ if (!current) return false;
+ const heading = HEADING_LEVEL_RE.exec(line);
+ return heading !== null && (heading[1] ?? "").length <= current.level;
+}
+
+function parseFrames(body, bodyStartLine, warnings) {
+ const lines = body.split(/\r?\n/);
+ const sections = [];
+ let current = null;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i] ?? "";
+ const opened = openFrameSection(line, bodyStartLine + i);
+ if (opened) {
+ sections.push(opened);
+ current = opened;
+ } else if (endsFrameSection(line, current)) {
+ current = null;
+ } else if (current) {
+ current.lines.push(line);
+ }
+ }
+ return sections.map((section, idx) => buildFrame(section, idx + 1, warnings));
+}
+
+function buildFrame(section, index, warnings) {
+ const frame = { index, status: DEFAULT_FRAME_STATUS, narrative: "", extra: {} };
+ const { number, title } = parseHeading(section.headingText);
+ if (number !== undefined) frame.number = number;
+ if (title) frame.title = title;
+
+ const narrativeLines = [];
+ for (const line of section.lines) {
+ const meta = META_RE.exec(line);
+ if (meta) {
+ applyMeta(
+ frame,
+ (meta[1] ?? "").toLowerCase(),
+ (meta[2] ?? "").trim(),
+ section.headingLine,
+ warnings,
+ );
+ } else {
+ narrativeLines.push(line);
+ }
+ }
+ frame.narrative = narrativeLines.join("\n").trim();
+ return frame;
+}
+
+function parseHeading(text) {
+ if (!text) return {};
+ const intMatch = LEADING_INT_RE.exec(text);
+ if (!intMatch) return { title: text };
+ const number = Number.parseInt(intMatch[1] ?? "", 10);
+ const rest = text
+ .slice((intMatch[0] ?? "").length)
+ .replace(/^[\s.:—-]+/, "")
+ .trim();
+ return { number, title: rest || undefined };
+}
+
+// Dispatch a recognized metadata key to its field, else stash under `extra`.
+// Mirrors core's META_SETTERS map exactly (direct keys + alias sets).
+function applyMeta(frame, key, value, headingLine, warnings) {
+ switch (key) {
+ case "duration":
+ applyDuration(frame, value, headingLine, warnings);
+ return;
+ case "status":
+ applyStatus(frame, value, headingLine, warnings);
+ return;
+ case "poster":
+ applyPoster(frame, value);
+ return;
+ case "src":
+ frame.src = value;
+ return;
+ }
+ if (TRANSITION_KEYS.has(key)) {
+ frame.transitionIn = value;
+ return;
+ }
+ if (SCENE_KEYS.has(key)) {
+ frame.scene = value;
+ return;
+ }
+ if (VOICEOVER_KEYS.has(key)) {
+ frame.voiceover = stripQuotes(value);
+ return;
+ }
+ frame.extra[key] = value;
+}
+
+function applyPoster(frame, value) {
+ const num = DURATION_NUM_RE.exec(value);
+ if (num) frame.poster = Number.parseFloat(num[1] ?? "");
+}
+
+function applyDuration(frame, value, headingLine, warnings) {
+ frame.duration = value;
+ const num = DURATION_NUM_RE.exec(value);
+ if (num) {
+ frame.durationSeconds = Number.parseFloat(num[1] ?? "");
+ return;
+ }
+ warnings.push({
+ message: `Frame ${frame.index}: could not parse duration "${value}".`,
+ line: headingLine,
+ frameIndex: frame.index,
+ });
+}
+
+function applyStatus(frame, value, headingLine, warnings) {
+ const normalized = value.toLowerCase();
+ if (isFrameStatus(normalized)) {
+ frame.status = normalized;
+ return;
+ }
+ frame.extra.status = value;
+ warnings.push({
+ message: `Frame ${frame.index}: unknown status "${value}"; defaulting to "${DEFAULT_FRAME_STATUS}".`,
+ line: headingLine,
+ frameIndex: frame.index,
+ });
+}
+
+function stripQuotes(value) {
+ if (value.length >= 2) {
+ const first = value[0];
+ const last = value[value.length - 1];
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
+ return value.slice(1, -1);
+ }
+ }
+ return value;
+}
diff --git a/skills/music-to-video/scripts/stage-assets.mjs b/skills/music-to-video/scripts/stage-assets.mjs
new file mode 100644
index 000000000..18f92bf9f
--- /dev/null
+++ b/skills/music-to-video/scripts/stage-assets.mjs
@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+// stage-assets.mjs — copy user-supplied media into the project's assets/ so scene
+// files (and lint/validate/render) can reference them locally. Only needed when
+// the user provides images/videos for asset treatments (montage.md). First-wins,
+// idempotent, safe to run twice. Never fetches remote URLs.
+//
+// Usage: node stage-assets.mjs --from --hyperframes
+// [--into public] (subdir under assets/; default copies flat into assets/)
+//
+// Copies common media extensions only; reports what landed.
+
+import { existsSync, mkdirSync, readdirSync, copyFileSync, statSync } from "node:fs";
+import { extname, join, resolve, basename } from "node:path";
+
+const argv = process.argv.slice(2);
+const flag = (n, d) => {
+ const i = argv.indexOf(`--${n}`);
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d;
+};
+function die(m) {
+ console.error(`✗ stage-assets.mjs: ${m}`);
+ process.exit(1);
+}
+
+const fromDir = flag("from", null);
+if (!fromDir) die("missing --from ");
+const fromAbs = resolve(fromDir);
+if (!existsSync(fromAbs) || !statSync(fromAbs).isDirectory()) die(`--from is not a directory: ${fromAbs}`);
+const hyperframesDir = resolve(flag("hyperframes", "."));
+const into = flag("into", "");
+const destDir = join(hyperframesDir, "assets", into);
+
+const MEDIA = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".mp4", ".mov", ".webm", ".m4v"]);
+
+mkdirSync(destDir, { recursive: true });
+let staged = 0,
+ skipped = 0;
+const landed = [];
+for (const name of readdirSync(fromAbs)) {
+ const src = join(fromAbs, name);
+ if (!statSync(src).isFile()) continue;
+ if (!MEDIA.has(extname(name).toLowerCase())) continue;
+ const dest = join(destDir, basename(name));
+ if (existsSync(dest)) {
+ skipped++;
+ continue;
+ } // first-wins
+ copyFileSync(src, dest);
+ staged++;
+ landed.push(join("assets", into, basename(name)));
+}
+
+console.log(`✓ stage-assets: ${staged} copied, ${skipped} already present → ${join("assets", into)}/`);
+for (const l of landed) console.log(` ${l}`);
+if (staged === 0 && skipped === 0) console.log(` (no media files found in ${fromAbs})`);
diff --git a/skills/music-to-video/scripts/validate-plan.mjs b/skills/music-to-video/scripts/validate-plan.mjs
new file mode 100644
index 000000000..9eb497725
--- /dev/null
+++ b/skills/music-to-video/scripts/validate-plan.mjs
@@ -0,0 +1,144 @@
+#!/usr/bin/env node
+// validate-plan.mjs — machine-check STORYBOARD.md against the audiomap + template
+// catalog at Step 3, before any frame is built. Runs on the PLAN (frame files do
+// not exist yet), so it checks fields, not on-disk html.
+//
+// HARD (exit 1): frontmatter duration_s == audiomap duration; >=1 frame; each frame
+// has src + positive duration; frames tile the track gap-free (sum == duration_s).
+// WARN (exit 0): best-effort group checks — each group exactly one of
+// template/free_design/asset; a template id exists under the --templates dir;
+// phrase_flow frame has no beat_cut asset treatment.
+//
+// Frame-level checks use the vendored storyboard parser. Group-level checks re-scan
+// the RAW source (the parser's META_RE consumes indented `- params:`/`- asset:` lines).
+//
+// Reads: --storyboard, --audiomap, --hyperframes (for templates/).
+
+import { existsSync, readFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { parseStoryboard } from "./lib/storyboard.mjs";
+
+const argv = process.argv.slice(2);
+const flag = (n, d) => {
+ const i = argv.indexOf(`--${n}`);
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d;
+};
+const hyperframesDir = resolve(flag("hyperframes", "."));
+const storyboardPath = resolve(flag("storyboard", join(hyperframesDir, "STORYBOARD.md")));
+const audiomapPath = resolve(flag("audiomap", join(hyperframesDir, "audiomap.json")));
+const templatesDir = resolve(flag("templates", join(hyperframesDir, "templates")));
+
+const errors = [];
+const warns = [];
+const r3 = (x) => Math.round(x * 1000) / 1000;
+
+if (!existsSync(storyboardPath)) {
+ console.error(`✗ STORYBOARD.md not found at ${storyboardPath}`);
+ process.exit(1);
+}
+const raw = readFileSync(storyboardPath, "utf8");
+const manifest = parseStoryboard(raw);
+const G = manifest.globals.extra ?? {};
+
+// ---------- audio duration ----------
+let audioDur = null;
+if (existsSync(audiomapPath)) {
+ try {
+ audioDur = JSON.parse(readFileSync(audiomapPath, "utf8"))?.audio?.duration_sec ?? null;
+ } catch (e) {
+ warns.push(`audiomap parse failed: ${e.message}`);
+ }
+} else {
+ warns.push(`audiomap not found at ${audiomapPath} — skipping duration cross-check`);
+}
+
+// ---------- frontmatter duration_s ----------
+const declaredDur = G.duration_s != null ? Number.parseFloat(G.duration_s) : NaN;
+if (!Number.isFinite(declaredDur)) errors.push(`frontmatter \`duration_s\` missing or unparseable`);
+else if (audioDur != null && Math.abs(declaredDur - audioDur) > 0.05)
+ errors.push(`frontmatter duration_s (${declaredDur}) != audiomap duration (${audioDur})`);
+
+// ---------- frames (hard) ----------
+const frames = manifest.frames;
+if (frames.length === 0) errors.push(`no frames (no \`## Frame N — \` headings found)`);
+
+let sum = 0;
+for (const f of frames) {
+ const label = `frame ${f.number ?? f.index}${f.title ? ` (${f.title})` : ""}`;
+ if (!f.src) errors.push(`${label}: missing \`- src:\``);
+ if (!Number.isFinite(f.durationSeconds) || f.durationSeconds <= 0)
+ errors.push(`${label}: missing/!positive \`- duration:\` (got ${JSON.stringify(f.duration)})`);
+ else sum += f.durationSeconds;
+}
+sum = r3(sum);
+const tileTarget = Number.isFinite(declaredDur) ? declaredDur : audioDur;
+if (tileTarget != null && Math.abs(sum - tileTarget) > 0.1)
+ errors.push(`frame durations sum to ${sum}s but the track is ${tileTarget}s — frames must tile it gap-free`);
+
+// ---------- group checks (warns) — parse RAW text ----------
+const FRAME_HEAD = /^##\s+(?:frame|scene|section)\b/i;
+const GROUP_HEAD = /^\s*[-*]\s*\*\*\s*(\w+)\s*\*\*\s*[—:-]\s*(template|free_design|asset)\b(.*)$/i;
+const templateExistsCache = new Map();
+function templateExists(id) {
+ if (templateExistsCache.has(id)) return templateExistsCache.get(id);
+ const ok = existsSync(join(templatesDir, id, "index.html"));
+ templateExistsCache.set(id, ok);
+ return ok;
+}
+
+// walk raw lines → group blocks tagged with their frame label + pacing
+const blocks = [];
+let frameLabel = "?";
+let pacing = "";
+let cur = null;
+for (const ln of raw.split(/\r?\n/)) {
+ if (FRAME_HEAD.test(ln)) {
+ frameLabel = ln.replace(/^#+\s+/, "").trim();
+ pacing = "";
+ cur = null;
+ continue;
+ }
+ const pm = ln.match(/^\s*[-*]\s*pacing\s*:\s*([A-Za-z_]+)/i);
+ if (pm && !cur) {
+ pacing = pm[1].toLowerCase();
+ continue;
+ }
+ const h = GROUP_HEAD.exec(ln);
+ if (h) {
+ cur = { frameLabel, pacing, name: h[1], kind: h[2].toLowerCase(), rest: h[3] ?? "", lines: [] };
+ blocks.push(cur);
+ continue;
+ }
+ if (cur) cur.lines.push(ln);
+}
+
+const framesWithGroups = new Set(blocks.map((b) => b.frameLabel));
+for (const f of frames) {
+ const lbl = `Frame ${f.number ?? ""} — ${f.title ?? f.index}`.replace(/\s+—\s+$/, "");
+ if (![...framesWithGroups].some((s) => s.includes(String(f.title ?? "")))) {
+ warns.push(`${lbl}: no parseable groups (expected \`- **gN** — template|free_design|asset …\`)`);
+ }
+}
+
+for (const b of blocks) {
+ const gid = `${b.frameLabel} / ${b.name}`;
+ const blockText = b.rest + " " + b.lines.join(" ");
+ if (b.kind === "template") {
+ const m = b.rest.match(/`([^`]+)`/) || b.rest.match(/:\s*([\w-]+)/);
+ const id = m ? m[1].trim() : null;
+ if (!id) warns.push(`${gid}: template kind but no template id on the head line`);
+ else if (!templateExists(id))
+ warns.push(`${gid}: template \`${id}\` not found at templates/${id}/index.html`);
+ }
+ if (b.kind === "asset" && b.pacing === "phrase_flow" && /beat_cut/.test(blockText))
+ warns.push(`${gid}: beat_cut asset treatment on a phrase_flow frame — use ken_burns/crossfade instead`);
+}
+
+// ---------- report ----------
+for (const w of warns) console.log(`⚠ ${w}`);
+if (errors.length) {
+ for (const e of errors) console.error(`✗ ${e}`);
+ console.error(`\nvalidate-plan: ${errors.length} error(s), ${warns.length} warning(s)`);
+ process.exit(1);
+}
+console.log(`✓ validate-plan: ${frames.length} frames tile ${sum}s; ${blocks.length} groups; ${warns.length} warning(s)`);
diff --git a/skills/music-to-video/sub-agents/frame-worker.md b/skills/music-to-video/sub-agents/frame-worker.md
new file mode 100644
index 000000000..df8188e72
--- /dev/null
+++ b/skills/music-to-video/sub-agents/frame-worker.md
@@ -0,0 +1,73 @@
+# Frame worker — per-frame composition author (music-to-video)
+
+You build one frame's composition file: `compositions/frames/.html`. Siblings build
+the other frames in parallel. The generic HyperFrames law — sub-composition shape, timeline
+registration, determinism, layout — lives in `hyperframes-core` (`references/sub-compositions.md`
++ `determinism-rules.md` + `data-attributes.md`); read it first. This file covers the
+music-specific part.
+
+Your job: **follow the manual, fetch the materials, assemble.** The storyboard tells you WHAT
+(the frame's groups, each group's template / primitives, content, brand, real beat-anchor
+seconds). You decide HOW (fetch the materials, bind them to this frame's audio seconds,
+micro-timing, layout, namespacing).
+
+## Inputs (your dispatch context)
+
+- `PROJECT_DIR` — project root; all paths relative to it.
+- `frame_id` — the frame file's stem, e.g. `02-f2`. Use it verbatim as the composition id, the
+ `window.__timelines` key, and the filename `compositions/frames/.html` (the
+ assembler matches on it).
+- Your **`## Frame N` block** in `STORYBOARD.md` — its `span_sec`, `pacing`, `mood`, `feel`, and
+ its **`### Groups`** list. Each group is one of:
+ - **template** — `template:` + `params` + `role_bindings` (real audiomap anchor seconds) + `copy`.
+ - **free_design** — `free_design:{dominant_system, primitives, density_topology}` + `anchors[]` + `copy`.
+ - **asset** — `asset:{treatment, clips, anchors?, overlay_copy?}` (see `montage.md`).
+- `audiomap.json` — timing truth; use the seconds you're given.
+- `frame.md` — the brand (palette + type). Pull every visual token from here.
+- **Materials** — `references/templates//index.html` (its `data-composition-variables` give
+ the param semantics) for template groups; `references/motion-primitives//index.html` for
+ free groups; staged `assets/…` for asset groups.
+- Canvas `×` and the frame's `pacing`.
+
+If your dispatch carries lint / validate feedback from a prior pass, address each finding.
+
+## What comes fixed — realize it as given
+
+- **The plan** is set in your `## Frame` block: the groups, templates / primitives, copy, brand,
+ and anchors. Build it as written. If a plan is genuinely wrong (wrong template or copy), stop
+ and report — the orchestrator re-plans at Step 3.
+- **Transitions** are the assembler's: it hard-cuts between frames. You author the frame's
+ **internal** group→group cuts only.
+- **Audio** lives on the root `index.html`; your frame is silent.
+- **GSAP** is loaded by the host; use the global `gsap` (your frame carries no gsap `