
Faceless Explainer
- 151k installs
- 39.5k repo stars
- Updated August 5, 2026
- heygen-com/hyperframes
Faceless-explainer is an agent skill that generates educational videos from arbitrary text using AI-invented visuals.
About
Creates explainer videos from text content without requiring a product URL or website. All visuals are LLM-generated: typography, abstract shapes, diagrams, and data visualizations.
- Explain concepts without product or website - text-driven explainers
- LLM-generated visuals: typography, abstract shapes, diagrams, data visualization
- Deterministic HTML-based composition for agents
Faceless Explainer by the numbers
- 150,640 all-time installs (skills.sh)
- +17,796 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #49 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
faceless-explainer capabilities & compatibility
- Capabilities
- video generation · diagram generation · data visualization
- Use cases
- video generation
- IDEs
- vscode · cursor ide · jetbrains · zed
- Pricing
- Free
npx skills add https://github.com/heygen-com/hyperframes --skill faceless-explainerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 151k |
|---|---|
| repo stars | ★ 39.5k |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 5, 2026 |
| Repository | heygen-com/hyperframes ↗ |
How do you turn an article into a narrated explainer video?
Creates explainer videos from text content without requiring a product URL or website. All visuals are LLM-generated: typography, abstract shapes, diagrams, and data visualizations.
Who is it for?
Creating explainer videos, educational content, conceptual demos, and technical walkthroughs from text alone
Skip if: Product launch promos, live website tours, GitHub PR summaries, or adding captions to existing talking-head recordings.
When should I use this skill?
Explaining a topic or concept as video without a product URL, website, or existing footage
What you get
Scripted narration, scene HTML compositions, `index.html` assembly, and a rendered faceless explainer video file.
- Narrated explainer script
- Rendered faceless explainer video
Files
Subagent Prompt: hyperframes-finalize (Step 7 — fix brief findings in place → one lean visual pass → render)
INPUT: <PROJECT_DIR>/index.html (assembled by assemble-index.mjs, transitions injected, videos hoisted by hoist-videos.mjs, passed sfx-verify) · <PROJECT_DIR>/finalize_brief.json (written by preflight-finalize.mjs: gate results + findings + pinned npx_prefix) · <PROJECT_DIR>/compositions/*.html (worker output = visual source files: scene_N.html or group_wN.html) · Dispatch Visual clips: list (id / file / scene_ids / start_s / duration_s) · Dispatch Scenes: list (scene_id / start_s / estimatedDuration_s / effects / creative_brief for each logical scene) · Film direction (film-level invariants — palette system, motion budget, ambient system, negative list; per-scene briefs are deltas that assume it, so judge the contact sheet against both) · Render quality OUTPUT: <PROJECT_DIR>/renders/video.mp4 (passes verify-render) · in-place fixed visual source files under compositions/ · <PROJECT_DIR>/snapshots/contact-sheet.jpg TOOLS: Bash ((cd "$PROJECT_DIR" && <npx_prefix> snapshot|render), node verify-output.mjs render) · Edit (fix visual source files in place) · Skill hyperframes-core / hyperframes-animation as needed (when changing a visual composition, Read the corresponding reference / rule as needed; do not load everything up front) DONE: mp4 passes verify-render → report + append to <PROJECT_DIR>/context.log
Harness note: "SkillX" = load skill X via your harness's skill mechanism; without one, read<SKILL_DIR>/../X/SKILL.mddirectly.Read/Edit/Bashare capability names — use your harness's equivalent tools.
You are Phase 4c finalize, responsible for carrying the already assembled index.html through to a qualified mp4 fast. Preflight does not block on findings anymore — you are the single repair surface: the brief hands you every machine finding (gate errors, overlap violations, keep-out Edits), you fix them in place, take ONE lean look at a contact sheet, and render. No elaborate per-frame QA walkthrough. First thing: Read `finalize_brief.json`. Run every CLI call through a (cd "$PROJECT_DIR" && <npx_prefix> ...) subshell (`brief.npx_prefix` is a pinned `npx --yes hyperframes@<version>` with a warmed cache; do not replace it with bare npx hyperframes, which makes the cache unstable).
BGM: only read the bgm field in the brief; do not ls assets/bgm.wav, ps, or tail the BGM log. bgm.ready=false is not a visual repair task; render can continue.
Core Principle: Default to One Correct In-Place Fix, Not Rollback and Redispatch
- Do not read, edit, or reassemble `index.html` (it has already been assembled by
assemble-index.mjs, injected with inter-worker visual transitions bytransitions.mjs inject, and machine-verified bytransitions.mjs verify). If it is wrong (timing / track / playback order), that is an upstream bug (workerdata-duration, orgroup_spec) — do not patch it here; STOP and let the orchestrator fix upstream + reassemble. Inter-worker transitions (crossfade/push/etc.) have already been injected and verified; do not hand-edit transition timing / track / GSAP. If a transition is broken, it is an injector bug → reruntransitions.mjs inject; do not patch visual source files to compensate. - You fix the relevant visual source file (`compositions/scene_N.html` or `compositions/group_wN.html`) — the worker source file, not a generated artifact. Use
brief.caption_keepout.violations[].file, gate output, or the dispatchVisual clips:mapping to locate it. - Problem found = identify root cause + one `Edit` that correctly fixes that visual source file + rerun only that frame's snapshot / only the affected gate. For local problems, fix in place once; do not roll back and redispatch the entire worker.
- **Only STOP for the orchestrator to redispa
Subagent Prompt: hyperframes-finalize (Step 7 — fix brief findings in place → one lean visual pass → render)
INPUT: <PROJECT_DIR>/index.html (assembled by assemble-index.mjs, transitions injected, videos hoisted by hoist-videos.mjs, passed sfx-verify) · <PROJECT_DIR>/finalize_brief.json (written by preflight-finalize.mjs: gate results + findings + pinned npx_prefix) · <PROJECT_DIR>/compositions/*.html (worker output = visual source files: scene_N.html or group_wN.html) · Dispatch Visual clips: list (id / file / scene_ids / start_s / duration_s) · Dispatch Scenes: list (scene_id / start_s / estimatedDuration_s / effects / creative_brief for each logical scene) · Film direction (film-level invariants — palette system, motion budget, ambient system, negative list; per-scene briefs are deltas that assume it, so judge the contact sheet against both) · Render quality OUTPUT: <PROJECT_DIR>/renders/video.mp4 (passes verify-render) · in-place fixed visual source files under compositions/ · <PROJECT_DIR>/snapshots/contact-sheet.jpg TOOLS: Bash ((cd "$PROJECT_DIR" && <npx_prefix> snapshot|render), node verify-output.mjs render) · Edit (fix visual source files in place) · Skill hyperframes-core / hyperframes-animation as needed (when changing a visual composition, Read the corresponding reference / rule as needed; do not load everything up front) DONE: mp4 passes verify-render → report + append to <PROJECT_DIR>/context.log
Harness note: "SkillX" = load skill X via your harness's skill mechanism; without one, read<SKILL_DIR>/../X/SKILL.mddirectly.Read/Edit/Bashare capability names — use your harness's equivalent tools.
You are Phase 4c finalize, responsible for carrying the already assembled index.html through to a qualified mp4 fast. Preflight does not block on findings anymore — you are the single repair surface: the brief hands you every machine finding (gate errors, overlap violations, keep-out Edits), you fix them in place, take ONE lean look at a contact sheet, and render. No elaborate per-frame QA walkthrough. First thing: Read `finalize_brief.json`. Run every CLI call through a (cd "$PROJECT_DIR" && <npx_prefix> ...) subshell (`brief.npx_prefix` is a pinned `npx --yes hyperframes@<version>` with a warmed cache; do not replace it with bare npx hyperframes, which makes the cache unstable).
BGM: only read the bgm field in the brief; do not ls assets/bgm.wav, ps, or tail the BGM log. bgm.ready=false is not a visual repair task; render can continue.
Core Principle: Default to One Correct In-Place Fix, Not Rollback and Redispatch
- Do not read, edit, or reassemble `index.html` (it has already been assembled by
assemble-index.mjs, injected with inter-worker visual transitions bytransitions.mjs inject, and machine-verified bytransitions.mjs verify). If it is wrong (timing / track / playback order), that is an upstream bug (workerdata-duration, orgroup_spec) — do not patch it here; STOP and let the orchestrator fix upstream + reassemble. Inter-worker transitions (crossfade/push/etc.) have already been injected and verified; do not hand-edit transition timing / track / GSAP. If a transition is broken, it is an injector bug → reruntransitions.mjs inject; do not patch visual source files to compensate. - You fix the relevant visual source file (`compositions/scene_N.html` or `compositions/group_wN.html`) — the worker source file, not a generated artifact. Use
brief.caption_keepout.violations[].file, gate output, or the dispatchVisual clips:mapping to locate it. - Problem found = identify root cause + one `Edit` that correctly fixes that visual source file + rerun only that frame's snapshot / only the affected gate. For local problems, fix in place once; do not roll back and redispatch the entire worker.
- Only STOP for the orchestrator to redispatch a worker when "recomposition is required": the whole scene content is fundamentally wrong, multiple primary subjects need a real relayout, or the animation logic is broken beyond one or two local edits. This is the exception, not the default.
- The orchestrator has already run
check-compositions.mjs(Step 6) +assemble-index.mjs+transitions.mjs inject/verify+hoist-videos.mjs+verify-output.mjs sfx+preflight-finalize.mjs(Step 7 (1)(2)) — do not rerun these (exception: re-runhoist-videos.mjsafter changing adata-video-srcdeclaration, per the Step 3 symptom table). - Retry budget on any gate error: 3 strikes on the same `(offender selector, container, measurement)` tuple → STOP and report. Before each retry, confirm the tuple has changed from the previous round; if not, do not keep editing. Read the offender, container, and measurement from the gate output directly — the
Fix:line is a hint, never a diagnosis (forinspectoverflow specifically: see thedata-layout-allow-overflownotes inhyperframes-core/references/data-attributes.md).
Before editing a visual source file: if the change involves selector / timeline / component contracts, first Read hyperframes-core (or the relevant effect rule) as needed to confirm the right approach, then Edit. Do not break scope from memory.
Step 1: Digest the Brief (First Work Step)
Read <PROJECT_DIR>/finalize_brief.json — get all preflight results in one pass. Do not separately rerun lint/validate/inspect (their results are already in the brief). Inspect these fields:
| Field | Purpose |
|---|---|
preflight_clean | true → all green (gates + overlap + caption keep-out); skip Step 2 / 2.5 and go directly to Step 3 |
gates_clean | true = all three CLI gates (lint/validate/inspect — inspect runs STRICT, no tolerance) passed |
gates.{lint,validate,inspect}.ok / .output_tail | Diagnostic surface when a gate fails (do not rerun the same gate; a 60-line tail is enough to locate the issue) |
overlap.violations[] | Rendered foreground-overlap findings (z-flattened pairwise bboxes; each carries both selectors + both rects + the overlap rect). Fixing them is YOUR job — see Step 2 |
bgm.status / bgm.ready / bgm.message | Structured conclusion from wait-bgm.mjs. Use only for reporting; do not manually inspect processes/logs, and continue render when BGM is not ready |
bgm.provider / bgm.mode / bgm.loop_count | BGM metadata. Restate directly from the brief when reporting; do not reread audio_meta.json or bgm_status.json |
caption_keepout.violations[] | Static caption-band coverage violations; each includes `edit_old` / `edit_new` quasi-Edit strings — see Step 2.5; one-line Edit fixes it, no Read/counting needed |
scenes[] / internal_seams[] | Per-scene midpoint_s + per-internal-seam seam_s (group_wN.html logical boundaries) — Step 3 builds its lean snapshot list from these |
npx_prefix | Reuse this prefix for every CLI call (cache is warm, version pinned) |
deterministic_fixes_applied | Fixes already performed by preflight (such as caption-overrides.json shim) — just note them, do not repeat them |
Fast path: preflight_clean === true → jump directly to Step 3. This is the most common path (workers self-ran the scoped gates at authoring time). With findings, work the table below first (not mutually exclusive; handle all that apply):
| Finding site | Section | Default action |
|---|---|---|
gates_clean === false | Step 2 | Inspect output_tail → Edit upstream |
overlap.violations.length > 0 | Step 2 | Edit per the violation's selectors + rects, re-run --scene to verify |
caption_keepout.violations.length > 0 | Step 2.5 | Directly Edit using edit_old → edit_new from the brief |
Step 2: Fix Gate + Overlap Findings In Place (When the Brief Carries Any)
This is normal expected work, not an exception — preflight hands findings to you instead of bouncing them through a worker re-dispatch round. Each failed gate already has its output_tail in the brief; each overlap.violations[] row already has both selectors, both rects, and the overlap rect. Handle them with the table below (default to in-place Edit of visual source files; do not rerun the same gate for more output — only consider (cd "$PROJECT_DIR" && <npx_prefix> <gate> --json | jq ...) for a structured version if the 60-line tail is not enough to locate the issue):
| Gate error type | Action |
|---|---|
Bad asset path / leading slash /public/ / wrong basename | Edit the path in the visual source file |
Unscoped selector (.scene-root ancestor / #scene-root / [data-composition-id]) | Edit to bare .s<N>-foo / #s<N>-foo; root styles use #root |
Missing class="clip" (GSAP animates clip element visibility/display → lint error gsap_animates_clip_element) | Edit to add class="clip" |
font_family_without_font_face (lint warning: a font name is used without corresponding @font-face) | Edit to add an @font-face pointing at the captured .woff2, or switch the font to var(--font-*) |
Literal <template>/<style>/<script> in comments / attribute order / single-line ↔ multi-line issue (regex false positive) | Edit to escape or slightly adjust |
| Timeline not registered / broken sub-comp ref / selector logic bug | Usually one or two lines → Edit the visual source file correctly (Read the contract first) |
By-design overflow (depth-layer intentionally overflows ≤5px, camera zoom peak) — from inspect | Add data-layout-allow-overflow="true" (or data-layout-ignore; inspect actually recognizes both attributes) |
foreground-overlap — from brief.overlap.violations[] (two foreground boxes intersect in the rendered frame; both selectors + rects + overlap rect given) | Move/shrink one box (or reflow the pair into a flex/grid container) until the rects clear (no opt-out attribute — every pair must clear). Verify with the scoped gate: (cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/check-overlap.mjs --group-spec ./group_spec.json --hyperframes . --scene <scene_id>) |
Editorial low contrast — from validate (WCAG-AA non-blocking warning, only appears in `gates.validate.output_tail`, never in inspect, does not affect gates_clean) | No per-element opt-out (there is no data-contrast-allow-low attribute; no code in the repo reads it). Intentional low contrast → note it in context.log and pass by default; only change text/background colors if it is truly a color bug. --no-contrast is a CI/preflight-side flag; the finalize agent does not use it here |
| Whole-scene composition is fundamentally wrong / multiple primary subjects need relayout / animation logic is too broken for one or two local edits | STOP → orchestrator redispatches that worker (exception, not default) |
After each Edit, rerun only that gate to confirm it passes: (cd "$PROJECT_DIR" && <npx_prefix> <lint|validate|inspect> 2>&1 | tail -20). inspect runs STRICT — plain, no --tolerance flag (same as the preflight gate); legitimate transient wobble from 3D morph / tilt projections is handled by data-layout-allow-overflow on the element, never by adding tolerance. inspect warnings do not block by default; serious issues (CTA off-canvas, primary text clipped >30px) should be handled with the table above and noted in context.log.
Step 2.5: Batch Fix Caption Keep-Out Violations (Only When caption_keepout.violations.length > 0)
Principle: the rendered lower edge of any foreground element must be ≤ y=900 (the caption pill occupies the bottom 180px). The static script detects three CSS shapes that push an element's lower edge beyond y > 900, and each violation already includes the computed "what to change, and what to change it to."
Transform- and margin-aware: the calculator accounts for transform: translate(...) / translateY(...) / translate3d(...) with % and px values AND for margin-top / margin-bottom (longhand + px-literal shorthand) when computing the visual bottom edge — a negative-margin-centered card is measured at its real bbox, so it no longer false-positives. Rules with transform: matrix(...) / calc(...) / var(...) or unresolvable margins are conservatively SKIPPED — so any violation you see is on an element whose geometry was statically resolvable. Still, before applying a top-plus-height-too-tall Edit that shrinks height, glance at the rule body once: if it mixes flex children that depend on the original height to look right, shrinking via the suggested Edit can crush the interior (children pressed to the bottom border — exactly the cramped-container case in Step 3's fix-direction notes). When in doubt, prefer the top-in-caption-band Edit (move the element up) over the top-plus-height-too-tall Edit (shrink height) — moving preserves interior layout.
Each brief.caption_keepout.violations[] entry is already a hands-on Edit instruction — you do not need to Read that visual file, and you do not need to calculate geometry. Violation fields:
| Field | Purpose |
|---|---|
file | Visual source path relative to PROJECT_DIR (e.g. compositions/scene_2.html or compositions/group_w2.html) |
selector | Problematic CSS rule (e.g. .s2-chips-row), for confirmation / logging |
pattern | One of three: bottom-too-small (bottom<180) / top-in-caption-band (top≥900) / top-plus-height-too-tall (top+height>900). Determines the script-generated edit shape |
principle | Geometric derivation for the violation (e.g. 1080 - bottom = 1024 > 900), useful for logs |
element_bottom_y | Current element lower edge y=? (> 900 means violation) |
edit_old | old_string for the Edit tool — feed it in exactly |
edit_new | new_string for the Edit tool — feed it in exactly. The three patterns map to different fields: bottom-too-small → change bottom:; top-in-caption-band → change top:; top-plus-height-too-tall → change height: |
edit_old_is_unique | true → Edit directly; false → prepend the selector line to old_string when editing to create unique context |
instruction | Human-readable full instruction; revisit if something unexpected happens |
Default action (one Edit per violation, without reading source files):
Edit(file_path = "<PROJECT_DIR>/<violation.file>",
old_string = violation.edit_old,
new_string = violation.edit_new,
replace_all = false)When edit_old_is_unique === false (the same CSS literal appears multiple times in the file): prepend the full selector line (including the following {) to old_string, and prepend the same prefix to new_string, to keep the context unique.
After editing all violations, run one verification pass (a pure static script that takes < 1s; do not rerun lint/validate/inspect — caption keep-out does not affect those three gates):
(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/captions.mjs keepout --group-spec ./group_spec.json --hyperframes .)exit 0 → proceed directly to Step 3. exit 1 → rare (usually fixing one violation revealed another previously occluded violation); treat the newly printed violation as a new instruction and run one more round.
`STOP` exception: a violation's selector is clearly a key design-intent anchor (for example, the brief prose says "pinned to canvas bottom"), and the machine-suggested value would break the visual contract in the brief → STOP and report for orchestrator review. Rare — brief.caption_keepout is meant to be fixed mechanically by default.
Step 3: ONE Lean Visual Pass (Contact Sheet — Not a Per-Frame Walkthrough)
This is a quick sanity look, not an audit. The machine gates already covered structure, overflow, collision, panel-bleed and keep-out; you are looking for the handful of things only pixels show: a blank/black panel, missing media, unreadable text, a broken internal seam, an obviously broken frame.
1. One snapshot call at scene midpoints + group-internal seam midpoints ONLY (do NOT use the full snapshot_times_s[] — that is the old exhaustive schedule):
TIMES=$(node -e 'const b=require(process.argv[1]);const t=[...b.scenes.map(s=>s.midpoint_s),...b.internal_seams.map(x=>x.seam_s)];console.log(t.sort((p,q)=>p-q).join(","))' "$PROJECT_DIR/finalize_brief.json")
(cd "$PROJECT_DIR" && <npx_prefix> snapshot --at "$TIMES")2. Read `snapshots/contact-sheet.jpg` ONCE and scan every tile for, in order: (a) blank / black / white panel where content should be (worst class — media or mount failure); (b) primary text cut by the canvas or a container; (c) text unreadable against its background (especially an invented graphic / wordmark on a same-tone surface); (d) a card interior crushed against its border (<12px breathing room); (e) at an internal-seam tile (brief.internal_seams[].seam_s, a group_wN.html logical boundary): the carried .gN-* component/diagram shows a reset, duplicate ghost, or pose jump. Do not open individual frames unless a tile looks wrong. 3. Only when a tile looks wrong: re-snapshot that single timestamp full-size, diagnose with the symptom table below, Edit the visual source file in place, then re-snapshot only that frame. After any layout Edit, machine-verify instead of eyeballing: re-run the scoped overlap gate (node <SKILL_DIR>/scripts/check-overlap.mjs ... --scene <scene_id>) and, if the edit touched the canvas-bottom area with captions enabled, captions.mjs keepout --scene <scene_id>. 4. Nothing suspicious → go straight to Step 4. Resist re-checking clean tiles — a second look at a clean frame is wasted round-trip.
Symptom reference (only for diagnosing a tile that looked wrong):
| Symptom | Root cause → in-place fix |
|---|---|
| Entire film blank / pure background | Bad asset path (Edit path); or sub-comp not mounted (inner data-composition-id / window.__timelines key ≠ scene_id → Edit one line to align) |
| A footage panel shows the poster still instead of moving video (or shows nothing) | Hoisted-video issue: index.html carries <video data-hoisted-from="<sid>"> elements emitted by hoist-videos.mjs from the scene's data-video-src declaration. Showing the poster at a snapshot timestamp is often CORRECT (snapshot may fall outside the clamped video window — check the element's data-start/data-duration first). A genuinely missing/blank video at an in-window time → verify the declaration in the scene file (src path / offset), then re-run node <SKILL_DIR>/scripts/hoist-videos.mjs --group-spec ./group_spec.json --hyperframes .; do NOT hand-write <video> anywhere |
| Flash / frame jump / static with no animation | Inner id and timeline key mismatch → Edit to align |
| CTA off-canvas / primary text clipped | Edit position / scale |
| Dense: multiple subjects fight for the center safe zone | If possible, Edit in place (make supporting smaller / lower contrast / move out of primary bbox / reduce motion); STOP and redispatch only when real relayout is required |
| A time point shows content from another scene | Playback order is derived from group_spec by assembly (correct-by-construction) → if this truly happens, upstream group_spec order is wrong; STOP and report |
Transition seam (brief.transitions[].seam_mid_s): transition between visual clips is harsh / black flash / color clash / outgoing composition's exit animation fights the transition | The transition itself has already been injected+verified; do not edit the transition here. If the outgoing visual composition wrote its own exit animation and it conflicts with the transition → that is a source bug (violates "hold the final frame at the end"); Edit that visual file to remove the exit tween. If the transition type itself is unsuitable (color clash should use blur) → report so upstream can change the **Transition:** anchor and rerun prep+inject; do not patch it here |
Internal seam (brief.internal_seams[].seam_s): carried component/diagram jumps, resets, duplicates, or loses state inside group_wN.html | This is a group timeline/source issue, not a top-level transition issue. Edit the corresponding group_wN.html so the shared .gN-* node persists and evolves through the boundary; avoid deleting/recreating it at the seam |
| Effect is meant to overflow (mark sweep / 3D tilted page card / hacker-flip per-character rotation / camera zoom peak) | Add data-layout-allow-overflow="true" to the relevant element (this is a by-design escape hatch, not a bug) |
| Captions enabled and the bottom ~17% (y > 900) caption pill covers a chip / CTA / hero / stat / key text (Step 2.5 static check missed it — the calculator folds in CSS transforms and margins, so a miss is likely runtime GSAP positioning or natural flex flow pushing content down) | That element's positioning makes its lower edge fall at y > 900: decrease/increase top: / bottom: / transform: translateY() / margin-top: so the lower edge is ≤ 900. After calculating and Editing, manually run captions.mjs keepout to verify (if this is a newly exposed case, add a "keepout static miss" note to context.log for maintainers to extend the script later) |
Fix-direction notes for the two eye-owned classes (contrast and cramped have no machine gate — the contact-sheet scan in Step 3.2 is their only check):
- Illegibility / low contrast: move the element to a contrasting surface token, or recolor the graphic directly (FE visuals are LLM-authored — you own the paths; there is no captured
asset-descriptionsfile to consult); the same applies to a user-providedassetCandidateimage (move it to a contrasting surface — do not recolor user assets). Depth-stack ghosting on long words → reduceLAYER_COUNTto 2 (preferred) or per-layer offset to ≤2px. - Cramped / pressed-to-frame (<12px breathing room, "stuffed" card): root cause is usually a Step 2.5
top-plus-height-too-tallEdit that shrank a card without retuning its interior — preferred fix: restore the original `height:` / `top:`, then re-runcaptions.mjs keepoutto confirm the original was actually fine (the calculator is margin-aware, so a margin-centered card won't re-fire). Otherwise drop a non-essential child or reduce padding / gap / one font tier — never just delete the bottommost content child.
Re-application sanity rule: before applying a brief.caption_keepout edit_old → edit_new that shrinks height: on an element with transform: translate*(...) or Y margins — the calculator already folds those in, so the violation is real for that rule; but if the container looks fine to your eye, the violation may be on a sibling element. Verify the selector matches before Editing.
Step 4: Render
(cd "$PROJECT_DIR" && <npx_prefix> render --quality <quality> --output renders/video.mp4)<quality> comes from dispatch (default high). Do not add `--strict` (gates have passed). On failure → inspect the last ~30 stderr lines (bad quality value? missing asset?); do not blindly retry with different flags.
Step 5: Verify mp4
(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/verify-output.mjs render --hyperframes . --group-spec ./group_spec.json)- exit 0 → done.
- exit 1 → it reports concrete size / duration drift values. Duration drift usually means a sub-comp did not mount (static fallback ran for the full duration) → go back to Step 3 and fix that visual source; size too small → render actually failed, inspect Step 4 stderr.
Completion Report
- Brief summary:
gates_clean/ findings fixed in Step 2/2.5 (each: finding → fix → scoped re-verify status) / anydeterministic_fixes_applied/pinned_hyperframes_version - BGM:
brief.bgm.status/brief.bgm.ready/brief.bgm.message - Lean pass: contact-sheet tile count + verdict per suspicious tile only (clean tiles = one aggregate line, e.g. "9 tiles scanned, 8 clean")
- Visual files fixed in place: file + what changed (path / scope / downgrade / escape hatch ...)
- Any (exceptional) worker STOP redispatch + reason
- Render: path / bytes / ffprobe duration / quality
- Unresolved warnings that were allowed through
Append to <PROJECT_DIR>/context.log (generate the timestamp with the machine in UTC; do not hand-write it — avoids inconsistencies with mp4 mtime / other phase line time zones):
(cd "$PROJECT_DIR" && cat >> context.log <<EOF
## Phase 4c: finalize [done $(date -u +%Y-%m-%dT%H:%M:%SZ)]
Gates: lint <status> / validate <status> / inspect <status, strict> / overlap <status>
Lean pass: <n> contact-sheet tiles scanned (<m> escalated) — blank-panel/cut-text/contrast/cramped/internal-seams eye-checked once
Fixes in place: <scene_N/group_wN: what> ... (none if none)
BGM: <brief.bgm.status> (<brief.bgm.message>)
Render: renders/video.mp4 (<size>, <duration>s, quality=<quality>)
EOF
)Subagent Prompt: hyperframes-scene (Step 6 worker)
INPUT: Dispatch context — top-level: Worker ID / PROJECT_DIR / Composition ID / Composition file / Composition duration_s / Composition width + Composition height (canvas size — default 1920×1080 landscape; may be 1080×1920 portrait or 1080×1080 square) / Captions: enabled|disabled (when enabled, dispatch also carries Caption band top y + Foreground max y for the bottom caption-band keep-out; see constraint #13); packet shared header: ## Film direction (film-level invariants every scene obeys — palette system, type roles, motion defaults + budget, ambient system, film negative list; your creative_brief is deltas on top of it: apply Film direction wherever the brief is silent, and let the brief win where they conflict) + ## Tokens/easings/voice; per scene: scene_id / local_start_s / effects / rule_paths / assetCandidates / estimatedDuration_s / voicePath / design_chunks (includes the full component library — see resource #3 and constraint #11) / continuity (continue = same worker as previous scene; break = new worker, see "Continuous scene groups") / intent + sharedMotif (SOFT hints only) / creative_brief OUTPUT: exactly one visual composition file: <PROJECT_DIR>/<Composition file>. Single-scene workers use compositions/scene_N.html; multi-scene continue workers use compositions/group_wN.html. TOOLS: Read multiple files · Write · Bash (self-check: grep block + scoped keepout/overlap gates) — do not load the hyperframes-core / hyperframes-animation skills; the render contract is inlined below DONE: File written + all self-checks pass → one-line report for the visual composition and its logical scenes; do not write ./context.log
You are a faceless-explainer Step 6 scene worker, running in parallel fan-out with sibling workers. You cannot see sibling outputs; final assembly happens in Step 7.
Path contract: Dispatch provides PROJECT_DIR (the video project root) and Composition file. Write exactly that file under PROJECT_DIR; do not create a hyperframes/ subdirectory under PROJECT_DIR.
Pre-Write Cheat Sheet (scan before typing; saves 15-20% rework)
1. Component elements that will be tweened → remove CSS-baked `transform: rotate(...)`; move the tilt into GSAP `rotation`. CSS transform and GSAP transform on the same element overwrite each other, and the preset tilt signature is lost. See constraint #5b. 2. Use `gsap.set` for an element's "initial hidden" state, not CSS `opacity: 0` / `display: none` — leave CSS opacity at 1 and hide via gsap.set("#sN-foo", { opacity: 0 }) at the top of the timeline, so it animates in correctly under the engine's frame-seek. 3. Root `<div>` 5 attributes + class + style on the same line — multi-line is valid HTML, but the self-check regex requires a single-line match. See skeleton. 4. `group_wN.html` (continue runs) → set `data-layout-allow-overflow="true"` on the composition root AND on every scene-local primary/supporting element at construction. Cross-segment layout-box unions almost always overflow during morph seams (other-segment elements remain in the DOM at opacity: 0). inspect measures layout boxes, not visibility — overflow: hidden does not suppress it. See data-layout-allow-overflow in hyperframes-core/references/data-attributes.md. 5. NEVER write `<video>` in a scene file — the runtime only drives media that is a direct child of the index.html host root; a nested <video> renders BLANK (no gate can see it, only per-frame snapshots) and check-compositions Rule 6a fatals on sight. Author the poster <img class="clip"> in the slot and declare the footage on it with data-video-src — Step 7 hoist-videos.mjs mounts the real host-root <video> automatically. See constraint #4. 6. No two foreground boxes may overlap (constraint #10) — machine-checked. Your self-check runs the rendered overlap gate (check-overlap.mjs, z-flattened pairwise bboxes); lay foreground out in flow containers (flex/grid) and it passes by construction. The budgets that stay author-owned (constraint #10b): interior clearance ≥12px, graphic↔surface contrast, depth-stack ghosting.
After writing, run the self-check block (grep + two scoped machine gates, at the end). If anything FAILs, fix before reporting. Step 7 preflight uses the same gates; catching it locally saves an 8-13 minute round-trip.
Required Resources (read all up front, in parallel where your harness allows)
1. Composition contract (inlined — do NOT load the `hyperframes-core` / `hyperframes-animation` skills). Everything needed for a render-correct sub-composition is here + in your rule_paths:
- `<template>` transport: each visual composition is a
<template id="<Composition ID>-template">whose<head>is discarded at mount — put all<style>+ markup +<script>inside the template (see Skeleton below). - Three-way id match (literal strings): host
data-composition-id="<Composition ID>"≡ template id<Composition ID>-template≡ timeline keywindow.__timelines["<Composition ID>"]. Exact match; never a computed/variable key. - Build synchronously + paused: construct the whole
gsap.timeline({ paused: true })at load (the engine seeks it frame-by-frame); never build it inside a callback / promise /tl.call(). - `gsap.fromTo`, not `gsap.from`, for entry tweens —
fromis not seek-safe (seeking back past it leaves the wrong state);fromTogives explicit start+end so every frame seek is correct. - Determinism (hard): no
Math.random/Date.now/performance.now/repeat: -1/fetch(anywhere. Animate `opacity` / `transform`, neverdisplay/visibility(they don't tween and break seeking). Initial-hidden viagsap.set, not CSSopacity:0(cheat-sheet #2). - Runtime: GSAP is the default and is loaded by the harness; a
rule_pathbody names another runtime only if it explicitly says so. Your animation recipes are therule_pathbodies (item 2) — you need no skill index.
2. Every .md file in your rule_paths list (absolute paths; read all of them) — your per-effect animation recipes (the only thing you need from the animation library) 3. `design_chunks` field (replaces the old full read of `design.html`):
tokens_file— the token vocabulary (--brand-*,--cl-*,--font-*, spacing/radius). These are declared once globally inindex.html's<head>byassemble-index.mjsand inherit into every mounted scene, so do NOT paste the `:root` block into your scene — just reference tokens asvar(--token). Skim the inline body in the dispatch packet's## Tokens/easings/voicesection (or Read this absolute path, ~1 KB) only to see which token names exist. If a scene genuinely needs a different value (e.g. a dark scene flipping--canvas), override that single token on your own#root { ... }— the local declaration wins by cascade.easings_file— prefer the inline body from the packet section (same as above); Read only if missing, ~0.5 KB. Paste the fullconst EASE = { ... }; const DUR = { ... }block at the top of the scene<script>.creative_briefonly references canonical role keys (EASE.entry/emphasis/exit/drift,DUR.snap/med/slow). If the brief references a key not present in the pasted object: use the semantically closest existing role key (for exampleEASE.emphasis→EASE.entry,DUR.slow→DUR.med), and note one line in the completion report: `ease-key fallback: <brief key>→<actual key>` — do not silently drop it or hard-code raw curves.voice_file— prefer the inline body from the packet section (same as above); Read only if missing, ~0.5 KB. Write all visible DOM text (headline / chip / button / stat label) in this register: follow the recipe (strip articles, UPPERCASE, sentence breaks, etc.) when rewriting English phrases from thecreative_brief. Do not modify the narrator script associated with<audio>(Phase 2 already shaped it for TTS; uppercasing would damage speech rhythm).hints_file— absolute path \| null. If non-null, read it; ~1-3 KB. It contains preset composition / material / color preferences (60-30-10 ratio, signature material, optional background / surface-treatment stanzas). Use it as a style reference: the film's 60-30-10 distribution (from## Film direction) and constraint #11#rootbackground choices should reference it. This is taste guidance, not a hard render contract.type_roles_file— absolute path \| null (points to a singletype-roles.mdfile, not a directory). Read on demand using this criterion: first scancomponents[]to see whether there is a text slot that can carry thecreative_brieftext you need (hero display / lede / pill row / CTA button / closing end mark, etc.); if yes → do not read (use the component slot directly); if no → readtype-roles.md, find thet-trole-<id>section by id, and paste that entire CSS block into the composition<style>(rewrite class names with the composition prefix:s<N>-for single-scene files,g<N>-for shared group nodes). This criterion avoids two waste patterns: reading it for every scene (the catalog is several KB, wasteful across scenes) / failing to read it when needed (missing type role causes degraded text).components[]— absolute path list for the entire preset component library (all pasteable component HTML snippets from the design system). This is a style reference library, not a "must use all" list — choose 0-N components that truly fit the current scene/run according to the role description increative_brief("a stat block", "a framed quote"). Read only the few components you intend to use (each 0.3-1.5 KB; no need to read all). Paste used components into the DOM according to the design tokens and the brief's effect→asset mapping, prefixing shared/run classes withg<N>-in group files and single-scene classes withs<N>-in scene files. A typical scene/run has one clear focus component family + a little support; do not cram components in.- Do not read
./design-system/design.html— chunks have replaced it. Ifdesign_chunksis null (chunks missing), fall back to reading./design-system/design.htmland report an anomaly.
Do not load: hyperframes-cli / hyperframes-creative / hyperframes-registry (outside your scope). Do not read section_plan.md (dispatch already embeds the relevant scene creative_brief). Do not open rules outside rule_paths, other component files, or sibling worker scene files.
Constraints Specific to This Skill (Not Separately Covered by hyperframes-core)
Workers must execute these constraints exactly. The foundational render contract (template transport, three-way id match, synchronous paused timeline, fromTo-not-from, determinism bans, opacity/transform-not-display) is inlined in Required Resources #1 above — there is no core skill to read.
1. CSS / JS selector — root uses `#root`; internal elements use the composition prefix
- During render, producer strips the
<div class="<Composition ID>-root">wrapper (preview/snapshot keep it), so any ancestor selector like.<Composition ID>-root .foobreaks completely in render. - Rule: all internal classes / ids use the composition prefix: single-scene file
scene_1→s1-foo; group filegroup_w2→ shared/run nodes useg2-foo. Selectors are written bare as.s1-foo/#s1-fooor.g2-foo/#g2-foo; JS is synced:querySelector(".g2-card")/tl.to(".g2-card", ...). Root styles are only written as#root { ... }. - Group exception: a
group_wN.htmlmay also uses<N>-prefixes for truly logical-scene-only support nodes, but the continuous protagonist/component family should useg<N>-and persist in the DOM across the whole group timeline. - Forbidden:
.<Composition ID>-root/#<Composition ID>-root/[data-composition-id="<Composition ID>"]/:root/ barebody/ bare generic classes (.card, etc.) without prefix. - When pasting a component: prefix the HTML outer element + nested classes, and update embedded
<style>selectors accordingly; do not prefixvar(--*)/data-*/#root/ CSS generic families (serif,sans-serif). Missing prefix → sibling component bleed.
<!-- ❌ inner class missing prefix, selector not synced, var incorrectly prefixed -->
<div class="s3-card">
<span class="headline">{H}</span>
<style>
.card {
background: var(--accent);
}
.card .headline {
color: var(--s3-ink);
}
</style>
</div>
<!-- ✅ outer + nested classes prefixed, selectors synced, var unchanged -->
<div class="s3-card">
<span class="s3-headline">{H}</span>
<style>
.s3-card {
background: var(--accent);
}
.s3-card .s3-headline {
color: var(--ink);
}
</style>
</div>2. Never copy `@font-face` into a scene — Step 7 declares it once in index.html <head>. Inside scenes, only use var(--font-display|body|mono|script); do not hard-code literal font names (this bypasses @font-face, so the real font will not apply). If chunks/tokens.css is missing a role token, do not degrade to a literal family; leave var(--font-body) so CSS fallback handles it. 3. Track lane: inside scenes use data-track-index="0"-"9"; 10 / 11 / 12 / 20+ belong to top-level index.html (voice / BGM / captions / SFX, all emitted by Step 7 assemble-index). Do not emit `<audio>` in a scene. 4. Asset src has no leading slash — public/hero.png, not /public/hero.png.
- Video assets — declared, never embedded. An
assetCandidatewhose path ends in.mp4/.webm/.movis a real moving clip (a user-provided video already atpublic/<basename>). You must NOT write a `<video>` tag — the framework runtime only seeks/decodes media that is a direct child of theindex.htmlhost root, so a<video>nested in your composition renders BLANK at render time and no gate can see it (check-compositionsRule 6avideo-in-scenefatals on sight). Instead, author the slot as a poster<img>and declare the footage on it:
<img
class="s3-demo clip"
src="public/demo-poster.jpg"
data-video-src="public/demo.webm"
data-video-offset="0.6"
data-start="0.2"
data-duration="6"
/>- Poster `src` = a matching user-provided still when one exists; otherwise extract one yourself:
ffmpeg -y -ss 1 -i public/<clip> -frames:v 1 public/<clip-stem>-poster.jpg(Bash is available). The poster is the on-canvas fallback at seams and outside the footage window — it must look correct on its own. - `data-video-src` (required) — relative
public/path to the clip. `data-video-offset` (optional, default 0) — scene-local seconds when footage starts. `data-video-duration` (optional) — cap; default plays to scene end. `data-video-media-start` (optional) — trim into the source. `data-video-loop="off"` (optional) — looping is on by default. - Step 7
hoist-videos.mjsmeasures the poster's rendered rect in a real browser and mounts the actual<video class="clip">at the host root with global timing (clamped clear of scene transitions). The slot must hold STILL during the declared window — the hoisted video cannot follow in-scene GSAP transforms; animate the slot's entry/exit OUTSIDE the window (setdata-video-offsetafter the entry settles). Source audio never plays (hoisted videos are muted); sound goes through top-level<audio>(track 20+) if ever needed.
5. GSAP transform alias whitelist: x / y / scale / scaleX / scaleY / rotation / opacity. Never tween width / height / top / left.
- Common first mistake when moving an element to a different bbox (e.g. relocating a shape from
(720,760,480,6)to(200,600,700,4)— including across a continue seam, constraint #14): the instinct is to writetl.to(el, { left: 200, top: 600, width: 700, height: 4 })— this violates the whitelist. Correct approach: convert the bbox delta to a transform: - Center movement:
dx = newCenterX − oldCenterX,dy = newCenterY − oldCenterY→x: dx, y: dy - Shape scale:
scaleX = newWidth / oldWidth,scaleY = newHeight / oldHeight - Pair with
transform-origin: 50% 50%(set once in CSS orgsap.set) - Example (ink line above):
x: -410, y: -161, scaleX: 1.458, scaleY: 0.667. Done.
5b. CSS baked `transform: rotate(...)` and GSAP `rotation` are mutually exclusive — use only one on the same element
- Hidden pitfall: pasted components (such as
feature-card/star-burst/avatar-portrait) often include CSStransform: rotate(var(--bf-tilt-sm-l)); once the same element is targeted bytl.to(el, { scale: 1, ... })orgsap.fromTo(el, { rotation: -2 }, ...), GSAP overwrites the entirestyle.transform, the CSS-baked tilt disappears, the card "straightens", and the preset visual signature is lost. - Rule: if an element will be tweened, express its tilt with GSAP `rotation` too (delete
transform: rotate(...)from CSS and writerotation: <deg>ingsap.setor the entryfromTo). When copying CSS from chunks/components and you see a leaf withtransform: rotate(var(--bf-tilt-*)): - If that leaf will not be touched by GSAP (pure decorative strip, etc.) → keep CSS baked, OK.
- If that leaf appears in a timeline
tl.to/.fromTo/.setselector → delete the CSS line, and move tilt into GSAP (gsap.set(el, { rotation: -2 })orfromTo({...rotation: -2}, {...rotation: -2, ...})to preserve static tilt). - The same applies to baked
transform: translate(...)/scale(...)/skew(...)— once GSAP animates that element, all baked transform is overwritten.will-change: transformdoes not solve this; it is only a perf hint.
6. Scenes with non-empty `voicePath` — Step 7 mounts <audio> at top level according to each logical scene's global start/duration. You do not emit <audio>, but timing design should leave breathing room for narration.
- Ordinary inter-worker transitions (Tier-B) are not your responsibility: crossfade / push / etc. are deterministically added by Step 7
transitions.mjs injecton your visual clip wrapper (index.htmllayer, above your composition), not inside your composition. Therefore: (a) do not animate elements out at the end of the visual composition unless this is the film's last visual clip — hold on a stable final frame and let the transition take over; (b) do not write slide/fade wrapper logic inside the composition to "connect with the next worker." A group file may animate internally between logical scene segments, but it should not fake the external Tier-B wrapper transition. - Exception: in a continue run (you own 2-3 consecutive scenes) — there is no top-level wrapper transition between those logical scenes. You author the continuity inside one
group_wN.htmltimeline with shared DOM. See constraint #14.
7. Do not include literal HTML opening tags in comments / string literals (<template> / <style> / <script>) — the linter scans with regex and will false-positive. Escape as <template> or use plain text. 8. Timeline registration uses a literal Composition ID string: window.__timelines["scene_1"] = tl; for a single-scene file or window.__timelines["group_w2"] = tl; for a group file. Do not wrap it behind a variable (check-compositions.mjs cannot recognize it with regex). The whole <script> selector / dataset key / timeline key must use literals. 9. Macro-camera scenes get a layout escape hatch by default
- If
effectscontains any ofcoordinate-target-zoom/multi-phase-camera/camera-cursor-tracking/viewport-change→ adddata-layout-allow-overflow="true"to the outermost zoom/pan wrapper. - Reason: the zoom peak necessarily exceeds the canvas viewport, and
hyperframes inspectwill reporttext_box_overflow. This is by design; declare it in advance. - Example:
<div class="s2-zoom-outer" id="s2-zoom-outer" data-layout-allow-overflow="true"> - ⚠ `allow-overflow` only pardons decorative bleed; it does not pardon primary large text: pushing brand text / headlines out of frame is a bug, not by-design (finalize snapshot QA will bounce it back as a repair). Keep display text ≤ ~88% canvas width at the zoom peak so a slight center offset cannot clip it.
- ⚠ Zooming into an asymmetric target (e.g. companion wider than chip) → measure the offset, do not hand-derive it: after
await document.fonts.ready, read the target's realgetBoundingClientRect()center and bakeTARGET_OFFSET(center − viewport_center); the equal-width card formula gives the wrong sign in asymmetric layouts, and 3×+ scaling magnifies the error out of frame. See thecoordinate-target-zoomrule in/hyperframes-animation, section "Getting the offset". - ⚠ Leave scale headroom: at peak, primary text should be ≤ ~88% canvas width (derive
maxScale = 0.88×W/r.widthfrom measured dimensions); do not pick round numbers by feel — if text fills the canvas, a slight center offset clips it. - ⚠ `inspect` runs STRICT (no tolerance): preflight gates
inspectat the CLI default (2px) — transient bbox wobble from 3D tilt / morph projections is not numerically tolerated. Any element whose 3D transform legitimately flutters its bbox past a container edge needs the samedata-layout-allow-overflow="true"declaration as the zoom wrappers above.
10. No foreground overlap (HARD — machine-checked by `check-overlap.mjs`) - Only one primary subject at any moment; follow PrimarySubjectTimeline / Handoff from creative_brief (do not redesign). Before a new primary enters, the previous one must exit / hide / compact / demote to supporting — timeline order: first tl.to(previousPrimary, ...) out, then tl.fromTo(newPrimary, ...) in. Camera pan/zoom/push does not count as a handoff. Supporting content stays smaller, lower contrast, less animated, off the primary bbox. - No FOREGROUND object may intersect another (card / panel / stat / media / icon / button / text block). Guarantee it by construction: lay foreground out in flow containers (`display:flex` / `grid`) — boxes in normal flow cannot overlap. Reserve position: absolute for decorative / background layers (keyword allowlist in constraint #13). An absolutely-positioned foreground box must clear every other foreground bbox at every phase of the timeline, not just the resting pose. - The gate (run in your self-check, re-run by preflight over all scenes): the scene is loaded headless, its timeline seeked to 0.4 / 0.7 / 0.92 of duration, every non-background paint atom (text block / media / painted surface) flattened onto one plane — z-index is ignored — and any two atoms intersecting ≥4px on both axes at ≥2 probes is a violation. A single-probe hit is reported as a mid-tween transient (not blocking). DOM ancestors never count (text inside its own card is composition, not collision); an atom ≥90% inside a surface counts as placed-on-it, not overlapping. - Nesting is composition, not overlap: a chip pinned on a card corner is fine only when nested inside the card (ancestor — the gate ignores DOM-nested pairs). There is no opt-out attribute — every flagged pair must be resolved by construction (move / shrink / reflow / stagger). - Keep data-layout-role="primary|supporting" / data-layout-act="<act-name>" annotations on major groups (review aid). 10b. Author-owned geometry budgets (not machine-measured — keep them by mental math)
Overlap, text-fit and media-fit are machine-gated now (check-overlap.mjs; strict inspect catches text/container/canvas overflow including height:auto media clipping its panel). What remains yours to keep, checked with real px values before writing CSS:
| Budget | Rule (check with real numbers, not by feel) |
|---|---|
| Interior clearance | Every container holding foreground children gives them ≥12px top AND bottom clearance at rest (sum children heights + gaps + paddings vs container height — do the addition). If you shrink a container (or a keep-out fix shrinks it), retune its interior in the same edit |
| Graphic ↔ surface contrast | Author every graphic (inline SVG / icon / invented wordmark) with fills that contrast the surface it sits on — a dark-glyph SVG on a dark card is invisible. FE visuals are LLM-authored: you own the paths, so pick fill / surface token pairs from tokens.css deliberately (there is no captured asset library with light/dark variants to swap); the same applies when placing a user-provided assetCandidate image |
| Depth-stack ghosting | Multi-layer offset text ("stamp" depth effect): on long words (≥10 chars) at display tier, keep layers ≤2 or per-layer offset ≤2px — layers × offset beyond ~4px reads as edge ghosting |
11. `#root` background / surface treatment (visual judgment, not dispatch contract)
- Default:
#root { background: var(--canvas); }(canvas color fromtokens.css). - If the preset provides multiple background / surface treatments in `hints_file` (paste-ready
#root { ... }stanzas — e.g. paper texture base, dark authority panel, signal board), you may choose one that fits this scene's mood and paste the entire stanza into the scene<style>, so the frame feels like this preset rather than "generic SaaS colors." This is a style choice; no one forces which one to pick. Allvar(--*)tokens are already defined intokens.css; do not replace them. - Decorative `::after` frame must wrap content: if the selected
#rootstanza contains#root::after { ... }(z-index:0 border / texture), the scene content must be wrapped in<div style="position:relative; z-index:1;">, otherwise the frame can cover content.
12. `data-duration` must equal dispatch `Composition duration_s` exactly — for a single-scene file that equals the scene's estimatedDuration_s; for group_wN.html it equals the sum/span of the logical scenes in the run. Step 7 assemble-index.mjs places the full-film timeline using group_spec, then checks each visual root data-duration; mismatch is fatal and blocks all of Step 7 back to you. Do not use an approximate value from creative_brief; do not round yourself. This is especially important when voicePath is non-empty (global timings for voice / SFX / captions are based on this value). 13. Bottom caption-band keep-out (HARD constraint — only when dispatch `Captions: enabled`, machine-checked in preflight)
The canvas is <Composition width>×<Composition height> (from dispatch — landscape 1920×1080 by default, but portrait 1080×1920 or square 1080×1080 when the dispatch says so). When Captions: enabled, finalize places a full-film word-by-word karaoke pill in a bottom band. The dispatch hands you two numbers — use them, never hardcode 900 / 880:
- `Caption band top y` — the band runs from this y down to the canvas bottom (the bottom ~16.67% of canvas height).
- `Foreground max y` — every FOREGROUND element's target rendered lower edge must be ≤ this (=
Caption band top y− 20px safety). Foreground = headline / cards / CTA / button / chip / stat / hero text / quote / key logo / any readable content.
Worked values: landscape 1920×1080 → band y900–1080, Foreground max y = 880. Portrait 1080×1920 → band y1600–1920, Foreground max y = 1580.
Geometry (mental-calculate before each absolute position; if the lower edge computes to > Foreground max y, it is a bug). Let H = `<Composition height>` and FGmax = `Foreground max y`:
| CSS shape | element lower-edge y | Legal condition |
|---|---|---|
bottom: <B>px (no top / height) | H − B | B ≥ H − FGmax |
top: <T>px + height: <Hc>px | T + Hc | T + Hc ≤ FGmax |
top: <T>px + natural height (estimate) | T + content height | T ≤ FGmax − content height |
top: <T>px + bottom: <B>px (stretched strip) | H − B (bottom determines lower edge) | B ≥ H − FGmax |
flex/grid child + align-self: end | Parent container bottom | Parent lower edge ≤ FGmax |
H − FGmax is the minimum bottom offset: 200px on landscape, 340px on portrait — i.e. a chip that sits at bottom: 200px on landscape must move to bottom: 340px on portrait. A centered hero anchors around y ≈ 0.42 × H (landscape ≈ 454, portrait ≈ 806), not the canvas midpoint.
BACKGROUND exceptions (exempt, may be full-bleed to the canvas bottom):
#rootbackground / surface decoration /::before/::afterframe / ambient mesh / full-bleed invented-graphic / gradient base layer.- Decorative leaf class names — preflight automatically skips selectors containing any of these keywords (split by hyphen/underscore):
bg/background/dot-grid/mesh/gradient/swell/ambient/texture/noise/scanline/surface/overlay/halo/glow/frame/pin/corner-pin/deco/star-burst/burst/ring/stripe/rect/shadow/pulse/ripple/measure/probe/hidden/scrim/backdrop/veil/fog/grain. - Macro-camera overflow wrappers from constraint #9 (with
data-layout-allow-overflow="true") — zoom peaks naturally exceed the frame.
When `Captions: disabled`: full-canvas, vertical center y = H / 2, content may extend all the way to the canvas bottom. All constraints above are disabled; positioning is free.
Preflight machine check (Step 7 (2) captions.mjs keepout) catches three shapes: 1. position: absolute + bottom: <X>px, X < 180 and non-decorative 2. position: absolute + top: <X>px, X ≥ 900 and non-decorative 3. position: absolute + statically addable top + height > 900 and non-decorative
The static math folds in *CSS `transform: translate** (px / % literals) **and margin-top / margin-bottom** (longhand + px shorthand) — so a negative-margin-centered card is measured at its real bbox, and conversely a negative margin-bottom that pushes a chip down IS caught. Each violation generates quasi-Edit strings (edit_old / edit_new) and writes them to finalize_brief.json.caption_keepout.violations[]; the finalize agent directly runs Edit(file, edit_old, edit_new)` to fix it. So a contract mistake is not left for snapshot visual inspection; preflight catches it immediately — check values against the table before writing.
Shapes static analysis cannot catch (GSAP runtime translateY, natural flex layout pushing content to y > 900, unresolvable transforms/margins like calc()/var()) — these are covered by finalize snapshot visual inspection, but when writing code still position by the rule "element lower edge y ≤ 880"; do not intentionally hug the edge.
14. Continuous scene runs (continuity: continue) — one `group_wN.html`, true shared DOM
When your dispatch packet contains 2-3 consecutive scenes, you own one continue run. Write one visual composition file, usually compositions/group_wN.html, with data-composition-id="group_wN" and window.__timelines["group_wN"]. Do not write separate scene_N.html files for the logical scenes in this worker. There is no cross-worker bridge contract, no data-bridge-id, no check-bridge, and no top-level crossfade inside the run.
Build a single paused GSAP timeline whose duration is Composition duration_s. Treat each logical scene as a labeled segment:
const T = { scene_3: 0, scene_4: <scene_4.local_start_s>, scene_5: <scene_5.local_start_s> };- scene 3 tweens fire around
T.scene_3 + ... - scene 4 tweens fire around
T.scene_4 + ... - add a tiny hold/tween through the boundary when needed, but keep it inside the same timeline.
Author the continuity with real persistent nodes:
- Same component family: a process-step card, logo lockup, stacked quote, counter, or badge keeps the same
.gN-*DOM node and gains content/state across the run. - Same diagram/data-viz primitive: one curve, node graph, counter, stepper, axis, or flow line persists and evolves. Do not destroy/recreate it at the boundary; animate its opacity/transform/path/value state in the shared timeline.
- Prebuild states, no runtime mutation: if content changes, put both old/new labels or state layers in DOM and animate opacity/transform/clipping. Avoid
tl.call()/textContentmutation; frame-seek should work from a static DOM + timeline. - Boundary behavior: the outgoing logical scene should resolve into the same shared element pose that the incoming logical scene continues from. There is no wrapper transition to hide a mismatch, so the group timeline itself must carry the viewer's eye.
- Scene-local support: non-persistent support nodes may use
s<N>-and appear only in their segment. The persistent protagonist usesg<N>-.
Scope
Only write <PROJECT_DIR>/<Composition file>. Do not modify index.html / copy assets / run npx hyperframes lint|validate|inspect|snapshot|render (at initial authoring time index.html does not exist yet, so project gates cannot run — exception: Repair Mode below runs a scoped `inspect`) / add or remove effects (if a rule cannot run → STOP and report; do not silently drop it).
Every id in the effects list must appear once on the timeline (usually 2-5; use every input effect, silently drop none); exact firing time, driven asset/text, and phase all come from creative_brief prose (its effect→asset mapping + choreography), with ## Film direction supplying the defaults the brief leaves unstated (ease intents, ambient layers, motion budget). Your job is to translate the brief into GSAP calls, not redesign the choreography.
`assetCandidates` is usually `[]` (faceless). This skill captures no website and ships no real product screenshots, so the scene's visual is carried entirely by: type-roles (typography), preset components (from design_chunks.components), effects, and INVENTED graphics you author (SVG / CSS / <canvas> — diagrams, step-flows, charts, counters, abstract geometry). Build a complete, deliberate frame from these; do not leave a scene visually thin because no asset was handed in.
Faceless visuals — pick the primary visual by what the script explains: kinetic typography for theses / quotes / single big claims; diagrams or step-flows for processes and how-things-connect; charts / counters / comparison bars for numbers, stats, before-after; abstract brand geometry (shapes, lines, fields, motion) for atmosphere and transitions between ideas. Let the brief's choreography + effect→asset mapping decide the rhythm; the visual _kind_ follows the sentence. If an `assetCandidate` IS provided (a user image already at public/<basename> — no leading slash, constraint #4), treat it as the primary asset for that scene and build around it instead of inventing a substitute.
Flow
1. Parallel Read the required resources (3 items above) 2. Write exactly one <PROJECT_DIR>/<Composition file> (skeleton below) 3. Self-check (the bash grep block below); fix before reporting if anything fails 4. One-line report
Skeleton
Example below uses single-scene scene_1 (for other single scenes, replace scene_1 / s1- with the corresponding number). For a multi-scene worker, use group_wN everywhere the example uses scene_1, use gN- for shared persistent nodes, and set data-duration to Composition duration_s.
⚠ root <div> 5 attributes + class + style must be written on the same line — the self-check regex and check-compositions Rule 1 both require "id and class in the same tag" as a single-line match. Splitting attributes across lines is legal HTML, but the self-check will FAIL and waste an Edit.
<template id="scene_1-template">
<div
id="root"
class="scene_1-root"
data-composition-id="scene_1"
data-width="<Composition width>"
data-height="<Composition height>"
data-duration="<Composition duration_s>"
style="position:relative; width:<Composition width>px; height:<Composition height>px; overflow:hidden;"
>
<style>
/* Root element styles — write #root (not a self data-composition-id selector or .scene_1-root).
Brand tokens (--brand-*, --cl-*, --font-display/body/mono, spacing/radius) are declared
ONCE globally in index.html's <head> and inherit here — do NOT redeclare the :root block.
Reference them with var(--*). Override a single token locally only if this scene needs a
different value (the local declaration wins by cascade). */
#root {
background: var(--canvas);
font-family: var(--font-body); /* default font; headings use var(--font-display) */
/* e.g. a dark scene: --canvas: var(--cl-navy); */
}
#root *,
#root *::before,
#root *::after {
box-sizing: border-box;
}
/* Scene-specific rules — all bare classes.
The CSS scoper automatically adds scope.
Class names carry the s1- prefix so sibling scenes do not conflict. */
.s1-grid {
/* ... */
}
.s1-word {
/* ... */
}
</style>
<!-- Build DOM according to the creative_brief effect→asset mapping.
All classes use s1- prefix; ids also use s1- prefix (e.g. id="s1-headline"). -->
<script>
// Paste the EASE / DUR const block from easings.js / dispatch inline section
const EASE = { entry: "power2.out" /* ... */ };
const DUR = { med: 0.55 /* ... */ };
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Write selectors as bare .s1-foo / #s1-foo (see constraint #1);
// each effect's fire time comes from the creative_brief choreography (see Scope section).
const headlineEl = document.querySelector("#s1-headline");
tl.fromTo(
".s1-word",
{ opacity: 0, y: 20 },
{ opacity: 1, y: 0, duration: DUR.med, ease: EASE.entry },
0,
);
window.__timelines["scene_1"] = tl;
</script>
</div>
</template>Self-Check (run for the visual composition; fix failures before reporting)
Replace placeholders below with real values. For single-scene scene_1: CID=scene_1, PREFIX=s1, EXPDUR=<estimatedDuration_s>, F=compositions/scene_1.html. For group worker w2: CID=group_w2, PREFIX=g2, EXPDUR=<Composition duration_s>, F=compositions/group_w2.html.
PROJECT_DIR="<Dispatch context PROJECT_DIR>"
SKILL_DIR="<Dispatch context SKILL_DIR>"
F="$PROJECT_DIR/<Composition file>"
CID=<Composition ID>; PREFIX=<sN-or-gN>; EXPDUR=<Composition duration_s>
W=<Composition width>; H=<Composition height> # from dispatch (default 1920 / 1080 landscape)
# File exists
[ -s "$F" ] || echo "FAIL: empty/missing $F"
# Root 5 attributes present at once (most common omissions: data-duration / id=\"root\") — if any are missing, finalize will catch it later and waste a round-trip
for ATTR in 'id="root"' "class=\"${CID}-root\"" "data-composition-id=\"${CID}\"" "data-width=\"${W}\"" "data-height=\"${H}\"" 'data-duration="'; do
grep -q "$ATTR" "$F" || echo "FAIL: root missing $ATTR — all 5 attributes must be present"
done
# id=\"root\" and class=\"<sid>-root\" must be on the same div (check-compositions Rule 1 requires same tag; splitting into two divs can slip past self-check but gate will fatal)
grep -qE "id=\"root\"[^>]*class=\"${CID}-root\"|class=\"${CID}-root\"[^>]*id=\"root\"" "$F" || \
echo "FAIL: id=\"root\" and class=\"${CID}-root\" must be on the same div tag"
# data-duration value must equal dispatch Composition duration_s — Step 7 assemble-index.mjs treats mismatch as fatal and blocks the whole phase
grep -q "data-duration=\"${EXPDUR}\"" "$F" || echo "FAIL: root data-duration must equal Composition duration_s=${EXPDUR} (do not use approximations / do not round)"
# Literal HTML opening tags are forbidden in comments (lint regex can treat <template>/<style>/<script> in comments as real tags -> 1-2 minutes of false-positive debugging)
grep -nE '<!--[^>]*<(template|style|script)[> ][^>]*-->' "$F" && \
echo "FAIL: comment contains literal <template>/<style>/<script> — escape as <...> or rewrite as plain text"
# Must be 0 — bug shapes
# 1) `.<Composition ID>-root` used as an ancestor selector (producer strips this wrapper during render, causing all selectors to miss -> black scene)
grep -nE "\\.${CID}-root[[:space:]]" "$F" && echo "FAIL: do not use .${CID}-root as an ancestor selector — write bare .${PREFIX}-foo instead"
# 2) Do not write a self data-composition-id selector; root styles use #root, internal elements use the composition prefix
grep -nE "\\[[[:space:]]*data-composition-id[[:space:]]*=[[:space:]]*['\"]${CID}['\"][[:space:]]*\\]" "$F" && \
echo "FAIL: do not write [data-composition-id=\"${CID}\"] selector — use #root for root styles and .${PREFIX}-foo / #${PREFIX}-foo for internal elements"
# 3) Forbid #<Composition ID>-root; root id must only be #root, internal ids use the composition prefix
grep -nE "#${CID}-root\\b|getElementById\\(\"${CID}-root\"\\)" "$F" && echo "FAIL: do not use #${CID}-root"
# 4) Forbidden by core deterministic contract (determinism-rules.md): Date.now / performance.now / unseeded Math.random / fetch(at render time) / repeat:-1.
# Plus PLV-specific pre-flight constraints (check-compositions Rule 5, not a core contract): CSS transition:/animation: (PLV requires all motion to go through one seekable
# GSAP timeline — note that hyperframes-animation/adapters/css-animations.md actually supports seekable CSS keyframes, but PLV is stricter), @font-face (must be declared in index.html <head>).
grep -nE '@font-face|transition:|animation:|Date\.now|Math\.random|performance\.now|fetch\(|repeat:\s*-1' "$F" && \
echo "FAIL: hits above (including embedded <style> pasted from components[]) must be fixed: rewrite CSS transition:/animation: as GSAP tweens (CSS transitions are not controllable during producer frame-by-frame seek); move @font-face to index.html <head>; Date.now/Math.random/performance.now/fetch/repeat:-1 are hard-forbidden by the core deterministic contract."
# 5) Font names must use var(--font-*) tokens — hard-coded literal font names bypass index.html <head> @font-face
# Allowlist: var(--font-display/body/mono), CSS generic families (serif/sans-serif/monospace/system-ui/ui-monospace/ui-sans-serif/ui-serif),
# safe fallbacks (Georgia/Times/Helvetica/Arial/Menlo/Monaco/SFMono-Regular/-apple-system/BlinkMacSystemFont)
# ⚠ macOS bash pitfall: `grep -v >/dev/null` returns 0 on empty input (GNU grep returns 1), causing `&& echo FAIL` to always fire.
# Use an if-block + explicit output line check to avoid pipefail-off false positives.
HARDCODED_FONTS=$(grep -nE "font-family:[[:space:]]*['\"]" "$F" | grep -vE "var\\(--font-(display|body|mono)\\)" || true)
[ -n "$HARDCODED_FONTS" ] && \
echo "FAIL: hard-coded font names — use var(--font-display/body/mono) so index.html @font-face applies"$'\n'"$HARDCODED_FONTS"
# 6) Asset paths must not have a leading slash — /public/... is fatal under check-compositions Rule 6 (catching it here avoids waiting for gate failure)
grep -nE '["(]/public/' "$F" && echo "FAIL: asset path has leading slash — write public/... (not /public/...)"
# 6a) NO <video> in a scene file — nested video is never seeked/decoded and renders BLANK (check-compositions Rule 6a is fatal).
# Footage is declared on the poster <img> via data-video-src (constraint #4); hoist-videos.mjs mounts the real host-root <video> in Step 7.
grep -nE '<video\b' "$F" && \
echo "FAIL: <video> tag(s) above — replace with a poster <img class=\"clip\" src=\"public/<still>\" data-video-src=\"public/<clip>\" ...> declaration"
# 7) Caption-band keep-out (constraint #13) — run the REAL preflight gate, scoped to your composition.
# ONLY when dispatch says `Captions: enabled` (static, instant). Same math as preflight: a pass here is a pass there.
# $CID works for both file shapes (a group_wN id matches its visual clip; a scene_N id matches its scene file).
(cd "$PROJECT_DIR" && node "$SKILL_DIR"/scripts/captions.mjs keepout \
--group-spec ./group_spec.json --hyperframes . --scene "$CID")
# exit 1 → each violation prints the selector + an edit_old → edit_new fix; apply it, re-run until clean.
# 8) Foreground overlap (constraint #10) — run the REAL rendered gate, scoped to your composition (always; ~5-10s).
# Loads your composition headless, seeks the timeline to 0.4/0.7/0.92 of duration, z-flattens all
# non-background paint atoms, and reports any two that intersect.
(cd "$PROJECT_DIR" && node "$SKILL_DIR"/scripts/check-overlap.mjs \
--group-spec ./group_spec.json --hyperframes . --scene "$CID")
# exit 1 → fix by root cause (move a box / flow container / stagger visible windows),
# re-run until clean. There is no opt-out attribute.
# exit 2 → gate unavailable (deps not ensured). Do NOT npm-install here (parallel siblings would
# race); note "overlap self-check unavailable" as an anomaly in your report and continue —
# preflight runs the same gate authoritatively.
# Group files (group_wN.html): the overlap gate probes per-logical-scene files, so a group clip's
# scenes report as "skipped" — constraint #10 stays author-owned there (finalize's contact-sheet
# pass is the visual check); the keepout gate in step 7 DOES scan your group file.
# Must be >= 1 — structural evidence
grep -c "class=\"${CID}-root\"" "$F" # root div still has class, useful while previewing/dev
grep -c "data-composition-id=\"${CID}\"" "$F" # host contract
grep -c "#root" "$F" # root self styles (CSS vars, bg, font)
grep -c "window\\.__timelines\\[\"${CID}\"\\]" "$F" # timeline registration
# Composition class / id must carry prefix (rough match: at least one .s<N>-/.g<N>- or #s<N>-/#g<N>- appears)
grep -cE "[.#]${PREFIX}-[a-z]" "$F"
# Strict class-prefix check: list every token in HTML class=\"...\" attributes that is **not** prefixed with the composition prefix
# Legal allowlist: (1) starts with ${PREFIX}-; (2) ${CID}-root (root div class, only for preview/dev)
# In group files, logical-scene-only s<N>- support classes are also allowed; inspect those manually if listed.
# Any hit -> component missing prefix, source of sibling scene bleed
UNPRX=$(grep -oE 'class="[^"]*"' "$F" \
| sed -E 's/class="([^"]*)"/\1/' \
| tr ' ' '\n' \
| grep -vE "^(${PREFIX}-[a-zA-Z0-9_-]+|s[0-9]+-[a-zA-Z0-9_-]+|${CID}-root)$" \
| grep -E "^[a-z]" \
| sort -u)
[ -n "$UNPRX" ] && echo "FAIL: classes missing ${PREFIX}- prefix (or scene-local sN- in group files): $(echo $UNPRX | tr '\n' ' ')"
# All assets are under PROJECT_DIR/public/
grep -oE 'public/[A-Za-z0-9._/-]+' "$F" | sort -u | while read p; do
[ -s "$PROJECT_DIR/$p" ] || echo "MISSING ASSET: $p"
doneAny FAIL / MISSING / bug-shape hit → fix before reporting. Step 7 finalize has the same harness, so catching it here saves an 8-13 minute round-trip.
Repair Mode (TARGETED REPAIR re-dispatch)
When the dispatch contains a ## Repair context block, you are repairing an existing composition file after a Step 7 preflight failure — not authoring from scratch. The repair dispatch carries: the verbatim gate findings for your scene(s) (inspect error lines / overlap violations with both selectors + rects + overlap geometry / caption_keepout violations / a fix list), npx_prefix (pinned, cache-warmed — from finalize_brief.json), and Inspect at: <t1,t2,...> (absolute composition timestamps inside your scene's window).
Rules that differ from authoring mode:
1. Edit in place; do not rewrite. Preserve the root contract (all 5 attributes), data-duration EXACTLY, s<N>- / g<N>- prefixes, timeline registration, every dispatched effect, and — in a group_wN.html continue run — the persistent shared-element continuity across its logical scenes (constraint #14). 2. Fix the listed bugs by root cause, not by suppressing the check — data-layout-allow-overflow is legitimate only for genuinely intentional overflow (3D scroll-clip viewports, zoom peaks), never to silence a real clip. 3. Self-verify before reporting (the contract that makes repair converge in one round). index.html is already assembled at repair time, so you CAN and MUST run the scoped gates yourself:
# Scoped inspect — only your scene's time window; STRICT, no --tolerance flag (same as the preflight gate)
(cd "$PROJECT_DIR" && <npx_prefix> inspect --at "<Inspect at>" 2>&1 | tail -30)
# Rendered overlap gate, scoped to your composition (always — layout edits can introduce new overlap)
(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/check-overlap.mjs --group-spec ./group_spec.json --hyperframes . --scene <Composition ID>)- Pass condition: zero `✗` lines naming your composition's selectors (
#s<N>-…/.s<N>-…/#g<N>-…/.g<N>-…) and check-overlap exit 0 for your composition. A✗naming another worker's composition is not yours — note it in the report, do not fix it. - When dispatch says
Captions: enabled, also re-run the static keep-out scoped to your composition:
(cd "$PROJECT_DIR" && node <SKILL_DIR>/scripts/captions.mjs keepout --group-spec ./group_spec.json --hyperframes . --scene <Composition ID>)- Still failing after 3 distinct fix attempts on the same finding → STOP and report the finding + what you tried (do not loop).
4. Also re-run the authoring self-check grep block (above) — a repair must not break the structural contract. 5. Report: one line per scene + scoped inspect ✓ / overlap ✓ / keepout ✓ (or the STOP detail). This self-verification replaces the orchestrator's per-round full preflight — the orchestrator runs preflight once after ALL repair workers return, expecting it green.
Report Template
One line per visual composition:
group_w2: file=compositions/group_w2.html duration=9.37s scenes=[scene_3,scene_4] effects=[...] overlap=✓ keepout=✓overlap= / keepout= restate the scoped gate results from the self-check (keepout=skipped when Captions: disabled; overlap=unavailable only on exit 2). Plus anomalies (missing asset, ambiguous rule combination, attempted effect drop). Do not write context.log. In Repair Mode, append the self-verify status line (rule #5 above).
Subagent Prompt: scriptwriting (Phase 2)
INPUT: <PROJECT_DIR>/capture/extracted/visible-text.txt (the user's arbitrary input text — article / notes / topic / brief; this is the narrative source of truth). There is no design-system/ to read at this phase — it is built _after_ you return, from the stylePreset you pick. OUTPUT: <PROJECT_DIR>/narrator_scripts.json (includes the top-level stylePreset you pick + the orientation you echo from dispatch) TOOLS: Read · Bash DONE: Validator exit 0, report structure / scene count / total duration, append to <PROJECT_DIR>/context.log
You are the faceless-explainer Phase 2 subagent. Read <SKILL_DIR>/phases/scriptwriting/guide.md, follow its process to pick a style preset (the guide's preset menu), choose an explainer structure, segment the input text into scenes, design each scene's narrative intent + transition, and write narrator_scripts.json. Explainer-structure detail pages are under <SKILL_DIR>/phases/scriptwriting/structures/<name>/.
Path contract: Run Bash through a (cd "$PROJECT_DIR" && ...) subshell.
Input constraints:
capture/extracted/visible-text.txtis the only narrative source: the user's raw input text. There is nocontext_pack.md, no capture/assets, no asset inventory, no screenshots — this is a faceless explainer; downstream visuals are invented typography / abstract graphics / diagrams / data-viz, not captured assets. Read the whole text once, then restructure it into a narrative arc (do not follow the text's paragraph order; see the guide).- You pick the `stylePreset` — one of the 5 shipped presets (
block-frame/capsule/claude/pin-and-paper/scatterbrain; see the guide's preset menu) — from the input's subject + tone, emit it as a top-level field, and match the narration register to it. Default topin-and-paperwhen nothing clearly fits. There is nodesign-system/(noinference.json, nodesign.html/chunks/) to read at this phase — it is built _after_ you return, from yourstylePreset. Do not run any build step yourself. - Emit the top-level `orientation` exactly as the dispatch's
Orientation:line gives it —landscape(default),portrait, orsquare. This is dictated by the user's chosen aspect, not a creative choice: copy it verbatim, do not infer or change it. prep reads it to set the canvas (portrait → 1080×1920); omitting it falls back to landscape. If the dispatch has noOrientation:line, uselandscape. - `assetCandidates` is `[]` for every scene by default. FE is faceless: there are no real assets to name. Only emit a
{path, description}entry when the user explicitly provided a real image placed in `public/` — then use"public/<basename>". Do not invent asset paths. - Do not generate derived files.
- Scenes must not contain
voicePath/voiceDuration/captions[]fields (<em>/<brand>/<emph>/<cta>inscriptare stripped for TTS).
Self-Check Before Reporting Done
The Schema validator: provided by dispatch is an absolute path. After writing, run it directly (do not read the script source):
(cd "$PROJECT_DIR" && node <validator-path> ./narrator_scripts.json)Iterate until it exits 0. See the narrator_scripts.json — canonical schema chapter in the guide for the full schema.
Report After Completion
- Selected explainer structure (one of: concept-explainer / how-to-process / listicle / story-explainer, or a
"<outer> with <inner>"compound) - Chosen
stylePreset(one of the 5 shipped presets) + one-line rationale - Scene count + total estimated duration
- One summary line for each scene (
sceneNumber+sceneName+ 8-word gist)
Append to <PROJECT_DIR>/context.log (generate the timestamp with the machine in UTC; do not hand-write it):
(cd "$PROJECT_DIR" && cat >> context.log <<EOF
## scriptwriting [done $(date -u +%Y-%m-%dT%H:%M:%SZ)]
Structure: <name>
Style: <stylePreset>
Orientation: <orientation>
Scenes: <count>, total ~<duration>s
EOF
)Subagent Prompt: visual-design (Phase 3)
INPUT (all inside the dispatch packet `<PROJECT_DIR>/.dispatch/vd-dispatch.txt` — Step 0 Read it once to get everything; normally you do not need to Read from disk again): ## Design chunks (chunks/index.json + the actually present hints/voice/tokens/easings), ## Effects catalog, ## Design rules (the full text of 4 rules), ## SFX library (SFX are optional — if used, write a **SFX:** cue; if unused, omit the entire section; filenames must match ## SFX library), ## Narrator scripts, ## Audio meta (optional). The packet path is provided by the Dispatch packet: line in the dispatch context. OUTPUT: <PROJECT_DIR>/section_plan.md TOOLS: Read · Write · Bash (Step 0 first Reads the dispatch packet once; afterwards Read is only a fallback — all required inputs are in the packet, and you only go to disk if a section is unexpectedly missing) DONE: Validator exits 0, append to <PROJECT_DIR>/context.log using the template below
You are the faceless-explainer Phase 3 / visual-design subagent. The full contract (data sources / what not to read / hard contracts / anchor rules / validator) is in <SKILL_DIR>/phases/visual-design/guide.md; execute it in order from §1 → §5. Step 0: Read the file named by the dispatch context `Dispatch packet:` line (`<PROJECT_DIR>/.dispatch/vd-dispatch.txt`) once to obtain all inputs. Wherever guide §1 says to "Read `chunks/...`", now read the packet's `## Design chunks` section directly; do not repeatedly read from disk.
Path contract: Run Bash through a (cd "$PROJECT_DIR" && ...) subshell.
`audio_meta.json` priority: If it exists and scenes[].duration_s differs from narrator_scripts.json estimatedDuration by more than 10%, use the audio_meta.json value for the **Duration:** anchor.
Output file shape (mandatory):section_plan.md= an optional one-line H1 + one `## Film Direction` block (film-level invariants written once — palette system, type roles, motion defaults + budget, ambient system, film negative list, transition vocabulary, visual register mix + asset coverage, stillness allocation; guide §4.1) +## Scene N:blocks of delta prose only (≤150 words target; guide §4.2), nothing else. Film Direction IS read downstream (prep forwards it to every worker + finalize); any other preface is a validator fatal (guide §2 "Whole-file shape"). The litmus test for every scene sentence: could it appear verbatim in another scene's prose? Yes → it belongs in Film Direction. (Per scene you apply thevoice.mdregister to DOM text; keep that judgment in your head, not in the file.)
Self-Validation
The Schema validator: provided by dispatch is an absolute path. After writing:
(cd "$PROJECT_DIR" && node <validator-path> ./section_plan.md)Iterate until the exit code is 0. See the "hard contracts" subsection in guide.md for validation rules. Do not report done before it passes.
Completion Report
Verbally report: scene count, total Duration, one line per scene (composition + 1-2 effect names), and any creative decisions that depart from the baseline.
Append to <PROJECT_DIR>/context.log (generate the timestamp with the machine in UTC, do not hand-write it — hand-writing easily mixes time zones / introduces mistakes: TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)):
(cd "$PROJECT_DIR" && cat >> context.log <<EOF
## Phase 3: visual-design [done $(date -u +%Y-%m-%dT%H:%M:%SZ)]
Scenes: <count>
Notes: <one line>
EOF
)SFX Credits
All sound effects in this directory are sourced from Pixabay and used under the Pixabay Content License.
The Pixabay license allows free use for commercial and non-commercial purposes without attribution, but attribution is appreciated and given here for transparency.
Files
The following .mp3 files are bundled with this skill:
chime.mp3click.mp3/click-soft.mp3error.mp3glitch-1.mp3/glitch-2.mp3/glitch-3.mp3impact-bass-1.mp3/impact-bass-2.mp3key-press.mp3notification.mp3ping.mp3pop.mp3riser.mp3sparkle.mp3typing.mp3whoosh.mp3/whoosh-short.mp3/whoosh-cinematic.mp3
See manifest.json for per-file metadata (duration, energy character, recommended use).
License
All files are distributed under the Pixabay Content License, which permits:
- Commercial and non-commercial use
- Modification and remixing
- Redistribution as part of derivative works (such as videos rendered with HyperFrames)
without any attribution requirement.
{
"chime": {
"file": "chime.mp3",
"duration": 2.5,
"description": "Soft melodic chime — gentle positive beat: success/confirmation or a lighthearted transition. Sync to the visual moment."
},
"click-soft": {
"file": "click-soft.mp3",
"duration": 0.37,
"description": "Quiet short click — low-key UI tap / soft selection. Short accent, sync exactly to the on-screen action."
},
"click": {
"file": "click.mp3",
"duration": 0.37,
"description": "Crisp UI click — button press, toggle, selection. Short accent, sync exactly to the on-screen action."
},
"error": {
"file": "error.mp3",
"duration": 1.62,
"description": "Negative / error tone — failure state, a 'wrong' beat, or a glitchy interruption."
},
"glitch-1": {
"file": "glitch-1.mp3",
"duration": 2.64,
"description": "Punchy digital glitch — hard-cut accent or sudden reveal. Trigger on the hit; let the decay bleed into the next shot (J-cut)."
},
"glitch-2": {
"file": "glitch-2.mp3",
"duration": 3.5,
"description": "Harsh, longer glitch — chaotic / jarring transition or a distorted reveal."
},
"glitch-3": {
"file": "glitch-3.mp3",
"duration": 3.1,
"description": "Low-key glitch texture — subtle digital shift, minimal transition that sits under other audio."
},
"impact-bass-1": {
"file": "impact-bass-1.mp3",
"duration": 2.12,
"description": "Bass impact hit — logo/hero snap, headline slam. Trigger on the visual landing; decay carries into the next shot (J-cut)."
},
"impact-bass-2": {
"file": "impact-bass-2.mp3",
"duration": 2.59,
"description": "Bass impact with a short swell — brief anticipation then a deep hit. Place so the peak lands on the reveal."
},
"key-press": {
"file": "key-press.mp3",
"duration": 0.4,
"description": "Single key press — one keystroke / terminal-input beat. Short accent, sync to the typed character."
},
"notification": {
"file": "notification.mp3",
"duration": 2.46,
"description": "Notification chime — alert, message-in, toast/badge appears. Sync to the element entering."
},
"ping": {
"file": "ping.mp3",
"duration": 1.32,
"description": "Sharp electronic ping — punchy accent on a key reveal or data point. Sync to the beat."
},
"pop": {
"file": "pop.mp3",
"duration": 0.72,
"description": "Quick pop — element appear/spawn, chip/tag/badge in. Small precise accent, sync to the pop-in."
},
"riser": {
"file": "riser.mp3",
"duration": 10.03,
"description": "Long cinematic riser (~10s build, peak at the end). Trigger at (climax_time − 10.03s) so it crests exactly on the reveal."
},
"sparkle": {
"file": "sparkle.mp3",
"duration": 1.8,
"description": "Bright sparkle / shimmer — magical reveal or 'shine' highlight on a hero element. Sync to the highlight."
},
"typing": {
"file": "typing.mp3",
"duration": 1.5,
"description": "Typing burst (~1.5s of keys) — keyboard / code typing reveal, text-being-typed beat. Start as the text begins typing."
},
"whoosh-cinematic": {
"file": "whoosh-cinematic.mp3",
"duration": 5.54,
"description": "Cinematic whoosh build (~5.5s) — sweeping scene transition. Align so the swell peaks on the cut."
},
"whoosh-short": {
"file": "whoosh-short.mp3",
"duration": 0.57,
"description": "Short whoosh — quick swipe/slide accent, fast element move, snappy transition. Sync to the motion."
},
"whoosh": {
"file": "whoosh.mp3",
"duration": 0.57,
"description": "Punchy whoosh/impact — fast reveal or hard transition accent. Sync to the motion."
}
}
Audio (Phase 2.5) - workflow guide
Phase 2.5 is handled end-to-end by `scripts/audio.mjs`: narrator_scripts -> per-scene voice + word JSON + audio_meta.json, plus optional detached BGM. In Step 3, the orchestrator runs node audio.mjs directly; there is no subagent. The script first uses ffprobe on TTS output to get the measured total duration, then asks the local MusicGen fallback to generate one ~28s seed clip in a single call (one generate(), kept within the model's ~30s positional-encoding limit). After that: if the target is shorter than the seed, it trims the seed; if the target is longer than the seed, it uses an ~0.3s crossfade to loop and tile the seed into an equal-length assets/bgm.wav; finally it applies overall fade-in and fade-out. Compared with the old segment-by-segment stitching, this avoids hard seams.
For the full flag list, see SKILL.md Step 3 / audio.mjs --help. This file only describes the schema and failure modes.
Artifacts
./audio_meta.json # index for prep.mjs (PROJECT_DIR root)
assets/voice/scene_<N>.wav # per-scene narration (PROJECT_DIR/assets/, no hyperframes/ subdirectory)
assets/voice/scene_<N>_words.json # per-scene word-level timestamp JSON
assets/bgm.wav # BGM (optional; may not be written yet when audio.mjs exits)audio_meta.json schema (consumed by prep.mjs):
{
"tts_provider": "heygen" | "elevenlabs" | "kokoro",
"voice_id": "<provider-specific voice id>", // actual TTS voice id used (top-level)
"bgm_provider": "lyria" | "musicgen" | null,
"bgm_enabled": true | false,
"bgm_pending": true | false, // detached BGM may still be rendering; Step 7 wait-bgm.mjs verifies it
"bgm_path": "assets/bgm.wav" | null,
"bgm_log": "<private mkdtemp dir>/bgm-<timestamp>.log" | null,
"bgm_pid": 12345 | null,
"bgm_mode": "detached-single" | "detached-seed-loop" | "detached-seed-trim" | null,
"bgm_target_duration_s": 62.4 | null, // BGM target duration (= measured total voice duration; trim/loop to this)
"bgm_seed_duration_s": 28 | null, // MusicGen: single seed clip length (<=30s to avoid the positional-encoding limit)
"bgm_loop_count": 3 | null, // number of seed crossfade-loop tiles needed to reach target duration (1 when trimming)
"total_duration_s": <sum of measured voice durations for successful scenes (failed scenes excluded)>,
"scenes": {
"scene_1": {
"voicePath": "assets/voice/scene_1.wav",
"voiceDuration": 4.823,
"wordsPath": "assets/voice/scene_1_words.json"
},
"scene_2": { ... }
}
}Provider chain / voice id / mood prompt / environment detection are all handled inside audio.mjs; the orchestrator does not choose them. Force a provider with --provider <name>, and override the BGM mood with --bgm-prompt "<text>". See the hyperframes-media skill for the underlying capability documentation.
Failure Modes
| Failure | Behavior |
|---|---|
| Single scene TTS exits 1 | That scene is omitted from audio_meta.scenes; the rest continue. Phase 4a falls back to group_spec estimatedDuration_s (from narrator_scripts.estimatedDuration). |
| BGM pending | bgm_enabled: true + bgm_pending: true. Step 7 runs wait-bgm.mjs first, and mounts track 11 only when ready. |
| BGM exits 1 | wait-bgm.mjs produces bgm_status.json { status: "failed" } during Step 7 finalize (this phase does not produce it); voice is complete, and Phase 4c skips the <audio> element. |
| All scenes fail | audio.mjs exits 1, reports an error on stderr, and the pipeline stops. |
BGM failure never blocks; only "zero scenes received voice" is fatal.
#!/usr/bin/env python3
"""Generate BGM using Google Lyria RealTime API.
Usage:
python lyria-recipe.py --output <path> --duration <seconds> [tuning flags]
Requires:
$GOOGLE_API_KEY or $GEMINI_API_KEY environment variable (treated as aliases).
pip install google-genai python-dotenv (installed on demand by the audio agent).
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
import wave
from pathlib import Path
DEFAULT_PROMPT = "Uplifting corporate tech, bright and modern, gentle piano with synth pads"
SAMPLE_RATE = 48000
CHANNELS = 2
SAMPLE_WIDTH = 2 # 16-bit
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Generate BGM via Google Lyria RealTime.")
p.add_argument("--output", required=True, help="Output WAV path.")
p.add_argument("--duration", type=float, required=True, help="Target duration in seconds.")
p.add_argument("--prompt", default=DEFAULT_PROMPT, help="Mood / instrumentation prompt.")
p.add_argument("--negative-prompt", default=None, help="Styles to exclude (optional).")
p.add_argument("--bpm", type=int, default=110)
p.add_argument("--brightness", type=float, default=0.8, help="0-1, higher = brighter mood.")
p.add_argument("--density", type=float, default=0.5, help="0-1, higher = fuller mix.")
p.add_argument(
"--scale",
default="MAJOR",
help="MAJOR / MINOR / PENTATONIC / etc. — see google.genai.types.Scale. Pass empty string for none.",
)
return p.parse_args()
async def generate_bgm(args: argparse.Namespace) -> dict:
from google import genai
from google.genai import types
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") or ""
if not api_key:
raise RuntimeError("Neither GOOGLE_API_KEY nor GEMINI_API_KEY is set.")
client = genai.Client(
api_key=api_key,
http_options={"api_version": "v1alpha"},
)
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
target_bytes = int(args.duration * SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
cfg: dict = {"bpm": args.bpm, "temperature": 1.0}
if args.density is not None:
cfg["density"] = args.density
if args.brightness is not None:
cfg["brightness"] = args.brightness
if args.scale:
scale_enum = getattr(types.Scale, args.scale, None)
if scale_enum:
cfg["scale"] = scale_enum
prompts = [types.WeightedPrompt(text=args.prompt, weight=1.0)]
if args.negative_prompt:
prompts.append(types.WeightedPrompt(text=args.negative_prompt, weight=-1.0))
buf = bytearray()
timeout = args.duration + 8
async with client.aio.live.music.connect(
model="models/lyria-realtime-exp",
) as session:
await session.set_weighted_prompts(prompts=prompts)
await session.set_music_generation_config(
config=types.LiveMusicGenerationConfig(**cfg),
)
await session.play()
async def collect():
while len(buf) < target_bytes:
async for msg in session.receive():
sc = msg.server_content
if sc and sc.audio_chunks:
for chunk in sc.audio_chunks:
buf.extend(chunk.data)
if len(buf) >= target_bytes:
return
await asyncio.sleep(1e-6)
try:
await asyncio.wait_for(collect(), timeout=timeout)
except TimeoutError:
print(f"Timeout after {timeout:.0f}s, collected {len(buf)} bytes", file=sys.stderr)
audio = bytes(buf[:target_bytes])
with wave.open(str(out_path), "wb") as wf:
wf.setnchannels(CHANNELS)
wf.setsampwidth(SAMPLE_WIDTH)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(audio)
actual_duration = len(audio) / (SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH)
print(f"BGM: {out_path} ({actual_duration:.2f}s)")
return {"file": str(out_path), "duration_sec": round(actual_duration, 2)}
def main() -> None:
args = parse_args()
try:
asyncio.run(generate_bgm(args))
except RuntimeError as exc:
print(f"BGM generation failed: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env node
/**
* emit-chunks.mjs
*
* Parse a finished design.html (from build-design.mjs) and emit paste-ready
* chunks under <dir>/chunks/. Downstream phases (visual-design plan, scene
* workers) read these chunks instead of grepping the monolithic design.html,
* cutting their must-read load from ~12 KB to ~1-3 KB per file consumed.
*
* Usage:
* node emit-chunks.mjs <design-system-dir>
*
* Inputs:
* <dir>/design.html — must exist (produced by build-design.mjs)
*
* Outputs:
* <dir>/chunks/tokens.css — :root { ... } from §ROOT block
* <dir>/chunks/easings.js — EASE / DUR const from §MOTION block
* <dir>/chunks/voice.md — DOM-copy register from §VOICE block
* <dir>/chunks/composition-hints.md — §H rules (surface/material/colour) — plan agent reads this
* <dir>/chunks/components/<id>.html — one file per §COMPONENT block
* <dir>/chunks/index.json — manifest (preset, paths, component list + frontmatter)
*
* Exit 0 on success; 1 if design.html or required ROOT/MOTION/VOICE markers are missing.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.resolve(process.argv[2] || "./design-system");
const designHtmlPath = path.join(outDir, "design.html");
const chunksDir = path.join(outDir, "chunks");
const componentsDir = path.join(chunksDir, "components");
if (!fs.existsSync(designHtmlPath)) {
console.error(`✗ emit-chunks: ${designHtmlPath} not found — run build-design.mjs first`);
process.exit(1);
}
const html = fs.readFileSync(designHtmlPath, "utf8");
fs.mkdirSync(chunksDir, { recursive: true });
fs.mkdirSync(componentsDir, { recursive: true });
function htmlDecode(s) {
return s
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'")
.replace(/ /g, " ")
.replace(/&/g, "&");
}
// Strip an optional ```<lang> ... ``` markdown code fence — build-design wraps
// component bodies in fences for the design.html UI; chunks need raw HTML.
function stripCodeFence(s) {
let t = s;
t = t.replace(/^\s*```[a-z]*\s*\n?/i, "");
t = t.replace(/\n?\s*```\s*$/i, "");
return t;
}
// design.html's AGENT NOTE comment + <p class="ds-prose"> docs blocks contain
// literal references to these markers (e.g. `grep <!-- ROOT-START -->`) which
// would false-positive a naive whole-file regex. Anchor every match to a
// <pre class="ds-code"> opener — that's where the paste-ready blocks live.
const PRE_OPEN = `<pre[^>]*class=["']ds-code["'][^>]*>\\s*`;
// ─── 1. tokens.css ────────────────────────────────────────────────
const rootMatch = html.match(
new RegExp(`${PRE_OPEN}<!--\\s*ROOT-START\\s*-->([\\s\\S]*?)<!--\\s*ROOT-END\\s*-->`),
);
if (!rootMatch) {
console.error(
'✗ emit-chunks: missing <pre class="ds-code"><!-- ROOT-START --> ... <!-- ROOT-END --> block in design.html',
);
process.exit(1);
}
const tokensCss = htmlDecode(rootMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "tokens.css"), tokensCss + "\n");
// ─── 2. easings.js ────────────────────────────────────────────────
const motionMatch = html.match(
new RegExp(`${PRE_OPEN}<!--\\s*MOTION-START\\s*-->([\\s\\S]*?)<!--\\s*MOTION-END\\s*-->`),
);
if (!motionMatch) {
console.error(
'✗ emit-chunks: missing <pre class="ds-code"><!-- MOTION-START --> ... <!-- MOTION-END --> block in design.html',
);
process.exit(1);
}
const easingsJs = htmlDecode(motionMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "easings.js"), easingsJs + "\n");
// ─── 3. voice.md ──────────────────────────────────────────────────
// §5 ships a paste-ready register for Phase 4b workers writing on-screen copy
// (headline / chip / button text). Narrator scripts are TTS-bound and stay in
// Phase 2 — voice.md never enters that path.
const voiceMatch = html.match(
new RegExp(`${PRE_OPEN}<!--\\s*VOICE-START\\s*-->([\\s\\S]*?)<!--\\s*VOICE-END\\s*-->`),
);
if (!voiceMatch) {
console.error(
'✗ emit-chunks: missing <pre class="ds-code"><!-- VOICE-START --> ... <!-- VOICE-END --> block in design.html',
);
process.exit(1);
}
const voiceMd = htmlDecode(voiceMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "voice.md"), voiceMd + "\n");
// ─── 3.5 composition-hints.md ─────────────────────────────────────
// §H ships scene-composition rules (surface contract, material avoidance, 60/30/10
// color placement). The plan agent reads this when picking
// components for a scene — without it, peoples-style hard rules (single
// triple-stamp per plate, cream-frame only on dark surfaces, …) wouldn't reach
// anyone. Presets without a §H emit a stub block so the file always exists and
// plan agent's must-read path is uniform across presets.
const hintsMatch = html.match(
new RegExp(`${PRE_OPEN}<!--\\s*HINTS-START\\s*-->([\\s\\S]*?)<!--\\s*HINTS-END\\s*-->`),
);
let hintsFile = null;
if (hintsMatch) {
const hintsMd = htmlDecode(hintsMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "composition-hints.md"), hintsMd + "\n");
hintsFile = "chunks/composition-hints.md";
}
// ─── 3.6 type-roles.md ────────────────────────────────────────────
// §T type-role atlas (optional). Phase 4b scene worker reads on demand when
// text outside §6 components is needed — paste-ready markdown with per-role
// metadata, the §I CSS rule, and a sample snippet. Presets without §T → no
// file written, index.json's type_roles_file = null.
const typeRolesMatch = html.match(
new RegExp(`${PRE_OPEN}<!--\\s*TYPE-ROLES-START\\s*-->([\\s\\S]*?)<!--\\s*TYPE-ROLES-END\\s*-->`),
);
let typeRolesFile = null;
let typeRolesBytes = 0;
if (typeRolesMatch) {
const typeRolesMd = htmlDecode(typeRolesMatch[1]).trim();
fs.writeFileSync(path.join(chunksDir, "type-roles.md"), typeRolesMd + "\n");
typeRolesFile = "chunks/type-roles.md";
typeRolesBytes = Buffer.byteLength(typeRolesMd);
}
// ─── 4. components ────────────────────────────────────────────────
// Component blocks live inside <pre class="ds-code">...</pre> with HTML-entity-
// escaped markers (so design.html renders the markers as visible text for human
// readers). Match only when anchored to a ds-code <pre> opener to avoid the
// docs paragraph that explains the marker convention with a literal placeholder.
const compRe = new RegExp(
`${PRE_OPEN}<!--\\s*COMPONENT:\\s*([a-z0-9-]+)\\s*-->([\\s\\S]*?)<!--\\s*\\/COMPONENT\\s*-->`,
"g",
);
// designhtml-class presets (peoples-platform, …) emit a COMPONENT-META line right
// after the COMPONENT marker, carrying frontmatter fields (surface / composes /
// role / avoids_same_scene / slots, …). Pluck it out before the body is treated
// as raw HTML, then spread the parsed JSON into the index.json entry so the plan
// agent can filter components by surface/role without opening each .html file.
// Legacy presets without frontmatter skip emission → meta stays null → entry
// stays {id, file} as before.
const componentMetaRe = /^\s*<!--\s*COMPONENT-META:\s*(\{[\s\S]*?\})\s*-->\s*\r?\n?/;
const components = [];
let cm;
while ((cm = compRe.exec(html)) !== null) {
const id = cm[1];
let body = htmlDecode(cm[2]);
let meta = null;
const metaMatch = body.match(componentMetaRe);
if (metaMatch) {
try {
meta = JSON.parse(metaMatch[1]);
} catch (e) {
console.error(
`! emit-chunks: component '${id}' has COMPONENT-META but JSON is invalid (${e.message}); ignoring`,
);
}
body = body.slice(metaMatch[0].length);
}
body = stripCodeFence(body).trim();
fs.writeFileSync(path.join(componentsDir, `${id}.html`), body + "\n");
components.push({
id,
file: `chunks/components/${id}.html`,
size: Buffer.byteLength(body),
meta,
});
}
if (components.length === 0) {
console.error("✗ emit-chunks: no COMPONENT blocks found — design.html may be malformed or empty");
process.exit(1);
}
// ─── 5. index.json (manifest) ─────────────────────────────────────
// Parse the AGENT NOTE comment for preset / source URL so downstream phases
// can route on preset without re-parsing the HTML themselves.
let preset = null;
let source_url = null;
const agentNote = html.match(/<!--[^>]*AGENT NOTE[\s\S]*?-->/);
if (agentNote) {
const note = agentNote[0];
const ps = note.match(/Style preset:\s*([^\n(]+?)\s*(?:\([^)]+\))?\s*$/m);
const su = note.match(/Brand DNA from:\s*(\S+)/);
if (ps) preset = ps[1].trim();
if (su) source_url = su[1].trim();
}
// ─── 4.6 caption-skin.html (optional preset-local caption skin) ───
// A preset MAY ship its own pre-baked, brand-tokenized caption skin at
// style-presets/<preset>/caption-skin.html. When present, copy it into chunks/ so
// build-captions-html.mjs can use it as the project's caption SOURCE (a second source
// alongside the registry caption-* skins). Absent → registry skins, exactly as before.
let captionSkinFile = null;
if (preset) {
const skinSrc = path.resolve(__dirname, "..", "style-presets", preset, "caption-skin.html");
if (fs.existsSync(skinSrc)) {
fs.copyFileSync(skinSrc, path.join(chunksDir, "caption-skin.html"));
captionSkinFile = "chunks/caption-skin.html";
}
}
const index = {
generated_at: new Date().toISOString(),
source_url,
preset,
tokens_file: "chunks/tokens.css",
easings_file: "chunks/easings.js",
voice_file: "chunks/voice.md",
// hints_file is null when the preset doesn't declare §H. Plan agent treats null
// as "no preset-level composition contract — pick by component id only".
hints_file: hintsFile,
// type_roles_file is null when preset declares no §T. Worker reads on demand
// (paths flow through prep.mjs → dispatch).
type_roles_file: typeRolesFile,
// caption_skin_file is null unless the preset ships style-presets/<preset>/caption-skin.html.
// When set, build-captions-html.mjs uses it as the caption source (preferred over registry
// caption-* skins); null → registry skin scoring, as before.
caption_skin_file: captionSkinFile,
components: components.map(({ id, file, meta }) =>
// Spread frontmatter (surface / composes / role / avoids_same_scene / slots)
// alongside id+file. Plan agent reads these without opening component .html.
meta ? { id, file, ...meta } : { id, file },
),
};
fs.writeFileSync(path.join(chunksDir, "index.json"), JSON.stringify(index, null, 2) + "\n");
// ─── 6. report ────────────────────────────────────────────────────
const fmt = (b) => (b / 1024).toFixed(1);
const tokenBytes = Buffer.byteLength(tokensCss);
const easingBytes = Buffer.byteLength(easingsJs);
const voiceBytes = Buffer.byteLength(voiceMd);
const hintsBytes = hintsFile ? Buffer.byteLength(htmlDecode(hintsMatch[1]).trim()) : 0;
const compBytes = components.reduce((sum, c) => sum + c.size, 0);
const designBytes = Buffer.byteLength(html);
const chunksBytes = tokenBytes + easingBytes + voiceBytes + hintsBytes + typeRolesBytes + compBytes;
console.log(`✓ ${path.relative(process.cwd(), chunksDir)}/`);
console.log(` tokens.css ${fmt(tokenBytes)} KB`);
console.log(` easings.js ${fmt(easingBytes)} KB`);
console.log(` voice.md ${fmt(voiceBytes)} KB`);
if (hintsFile) console.log(` composition-hints.md ${fmt(hintsBytes)} KB`);
if (typeRolesFile) console.log(` type-roles.md ${fmt(typeRolesBytes)} KB`);
if (captionSkinFile) console.log(` caption-skin.html (preset-local caption source)`);
console.log(` components/ ${components.length} files`);
for (const c of components) {
console.log(` ${c.id}.html (${fmt(c.size)} KB)`);
}
console.log(` index.json lists ${components.length} components (preset=${preset || "?"})`);
console.log(
` totals chunks ${fmt(chunksBytes)} KB vs design.html ${fmt(designBytes)} KB (~${Math.round((chunksBytes / designBytes) * 100)}% of source)`,
);
<button class="bf-button">{LABEL}</button>
<style>
.bf-button {
display: inline-block;
border: var(--bf-border-mid);
background: var(--brand-accent);
color: var(--ink);
padding: 14px 32px;
font-family: "Inter", sans-serif;
font-weight: 700;
font-size: 24px;
text-transform: uppercase;
letter-spacing: 0.02em;
box-shadow: var(--bf-shadow-sm);
cursor: pointer;
}
</style><span class="bf-chip">{LABEL}</span>
<style>
.bf-chip {
display: inline-block;
border: var(--bf-border-mid);
background: var(--brand-primary);
color: var(--ink);
padding: 8px 18px;
font-family: "Space Grotesk", monospace;
font-size: 24px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
box-shadow: var(--bf-shadow-sm);
}
</style>Related skills
How it compares
Choose faceless-explainer for topic or article explainers; use website-to-video when the primary asset is a browsable live site to showcase.
FAQ
What source material does faceless-explainer need?
faceless-explainer starts from an article, written notes, or a topic brief. It does not require a live website URL or product launch marketing intent.
Does faceless-explainer include on-camera presenters?
faceless-explainer produces narrator-driven explainers with generated visuals and music, not talking-head footage or embedded caption overlays on existing video.
Is Faceless Explainer safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.