
Product Launch Video
- 151k installs
- 39.5k repo stars
- Updated August 5, 2026
- heygen-com/hyperframes
Product-launch-video is an agent skill that generates marketing videos for product announcements and launches.
About
Creates marketing videos for product launches and promotions. Takes a product URL, brief, or script and generates a video optimized for 30-90s (up to 3 minutes).
- Marketing and product launch videos from URL, brief, or script
- Optimized for 30-90s duration sweet spot, up to 3 minutes
- Automated composition from minimal product information
Product Launch Video by the numbers
- 150,539 all-time installs (skills.sh)
- +18,586 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #50 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
product-launch-video capabilities & compatibility
- Capabilities
- video generation · marketing automation
- Use cases
- video generation
- IDEs
- vscode · cursor ide · jetbrains · zed
- Pricing
- Free
npx skills add https://github.com/heygen-com/hyperframes --skill product-launch-videoAdd 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 create a SaaS product launch promo video?
Creates marketing videos for product launches and promotions. Takes a product URL, brief, or script and generates a video optimized for 30-90s (up to 3 minutes).
Who is it for?
Creating product launch videos, feature announcements, promotional clips, and marketing materials
Skip if: General website portfolio tours, educational explainers without a product, GitHub PR summaries, or captioning existing footage.
When should I use this skill?
Announcing a product launch or creating marketing video for a new feature
What you get
Planned launch storyboard, HyperFrames compositions, and a rendered ~60-second product marketing video.
- Launch video storyboard plan
- Rendered product promo MP4 or export
By the numbers
- Targets polished ~60-second product launch and SaaS promo videos
Files
Product Launch to HyperFrames
Use this skill to capture a product, understand its brand, plan a launch video, and build it frame by frame in HyperFrames.
Confirm the route before Step 0. You are the orchestrator. Run each step, verify its gate, and only then continue to the next step. This skill is for a product being marketed, launched, promoted, or revealed, including requests such as "promo for our site" when the purpose is promotional. Route other intents elsewhere: a general non-launch website tour ->/website-to-video; a topic explainer with no product ->/faceless-explainer; a GitHub PR ->/pr-to-video; captions on existing footage ->/embedded-captions; a short unnarrated motion graphic ->/motion-graphics. If the user says only "make a video" or the route is uncertain, read/hyperframesfirst.
You are the orchestrator. Work in videos/<project>/. Run steps in order and pass each gate before continuing. User-gated steps are Step 0, Step 3, and Step 6. Do every step yourself except Step 5, where you dispatch one sub-agent per frame. Do not put design or motion rules here; those live in the frame-worker sub-agent, hyperframes-creative, and hyperframes-animation.
Workflow: Step 0 setup -> hyperframes.json; Step 1 capture -> capture/; Step 2 design system -> frame.md; Step 3 storyboard/script -> STORYBOARD.md and SCRIPT.md; Step 3.1 audio -> audio_meta.json; Step 4 visual design -> enriched STORYBOARD.md; Step 5 frames -> compositions/frames/NN-*.html and index.html; Step 6 final render -> renders/video.mp4.
---
Step 0: Setup and Brief
Goal: Lock the core video brief and create the HyperFrames project if needed.
Initialize only if hyperframes.json is missing. Name <project> from the brand or domain in kebab-case, such as acme-promo; never use workspace name or timestamp.
npx hyperframes init "videos/<project>" --non-interactive --skip-skills --example=blank
Gate: hyperframes.json exists, and angle, length, aspect ratio, and language are locked.
---
Step 1: Capture assets
Goal: Collect the source material, brand signals, and usable assets for the video.
Classify the input and choose the path. Explicit URL -> capture it and use the site for narration and assets. Pasted script/brief -> save verbatim as user_script.txt, ask once "use it verbatim or restructure?", store answer as VO_MODE, then resolve capture target: URL in text -> use it; brand name only -> WebSearch, confirm URL in one line, then crawl; no URL/site -> no-capture path.
Run capture with: npx hyperframes capture "<URL>" -o ./capture
If GEMINI_API_KEY, GOOGLE_API_KEY, or an OpenRouter key exists, capture auto-captions assets into capture/extracted/asset-descriptions.md. This is not a review gate. Without a vision key, use DOM context and continue.
No-capture path: create capture/extracted/tokens.json, capture/extracted/visible-text.txt, capture/extracted/asset-descriptions.md, and capture/assets/ by hand. tokens.json should be { "title": "", "description": "", "colors": [], "fonts": [] }; fill title/description from the brief when possible. visible-text.txt contains the full brief or script. asset-descriptions.md should say no assets were captured unless the user gave asset notes.
Gate: capture/extracted/tokens.json, capture/extracted/visible-text.txt, capture/extracted/asset-descriptions.md, and capture/assets/ exist; you can state the brand in one clear sentence. Treat asset-descriptions.md as the main asset inventory. If it is missing after real capture, stop and report capture incomplete. If capture/BLOCKED.md exists, follow it.
---
Step 2: Design System
Goal: Choose one shipped frame preset; a script turns it into this video's frame.md + caption skin.
You make the one judgment call — which preset. Read ../hyperframes-creative/references/design-spec.md and pick the preset whose look best fits the brand and brief. Then run:
node <SKILL_DIR>/scripts/build-frame.mjs --preset <name> --hyperframes .The script does the rest deterministically: copies the preset's FRAME.md → frame.md and remixes it onto the brand tokens in capture/extracted/tokens.json (brand colors mapped onto the preset's color keys by role — ink, canvas, accents — keeping keys/structure/components; the preset's display + body fonts swapped for the brand's), copies the preset's caption-skin.html verbatim, and self-validates (exits 1 on a broken mapping). Proceed to the next step as soon as it exits 0 — no hand-editing of the spec.
tokens.json with no brand colors/fonts (e.g. no capture) → the script keeps the preset's own palette, a complete shippable design. If the brief names brand colors/fonts the capture missed, add them to capture/extracted/tokens.json before running (or use the user's design.md to populate it); only adjust frame.md by hand afterward if a mapping truly needs it.
Gate: build-frame.mjs exited 0 — frame.md exists from a named preset, and (when the preset ships one) caption-skin.html is at the project root.
---
Step 3: Storyboard and Script
Goal: Turn the brief and captured material into an approved frame-by-frame story plan.
Read references/story-design.md, ../hyperframes-core/references/storyboard-format.md, and ../hyperframes-core/references/script-format.md. Use them to write STORYBOARD.md and, when narration is needed, SCRIPT.md.
Use story-design.md for story archetype, hook, persuasion logic, beats, VO_MODE, and asset choices. Choose each visual frame's asset_candidates from capture/extracted/asset-descriptions.md (the canonical inventory) — don't browse raw capture/assets/. Do not ask the user to pick assets unless that inventory is missing or unusable. Use the exact required fields from the storyboard and script references.
After drafting, show a frame-by-frame summary. In that same message ask the user two things: (a) to approve or request changes, and (b) whether they want a live preview of the storyboard scaffold (npx hyperframes preview) — open it only on a yes. Iterate until approved, and carry the preview choice to Step 6.
Gate: STORYBOARD.md exists, every visual frame has asset_candidates, SCRIPT.md exists when narration is needed, and the user approved the frame-by-frame plan.
---
Step 3.1: Audio
Goal: Generate narration, word timings, music, and audio metadata from the approved script.
Start audio after Step 3 approval. Run it in the background, then continue to Step 4.
node <SKILL_DIR>/scripts/audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json &
The audio script handles narration, word timings, BGM lookup from HeyGen's music library, and timing metadata. BGM mood comes from the storyboard's music: field. This uses the HeyGen Audio API for retrieval, not generation, and uses the same ~/.heygen credential as TTS. For provider details, read ../hyperframes-media/references/tts.md.
If there is no narration and no SCRIPT.md, skip voice generation. BGM may still run if the storyboard has a music mood.
Gate: audio job has started, or the project is marked silent.
---
Step 4: Frame Visual Design
Goal: Add the visual direction, layout intent, and motion choices to each storyboard frame.
Edit STORYBOARD.md in place. Do not create another storyboard. Use frame.md as source of truth for color, type, layout feel, and style.
Read references/visual-design.md, references/composition.md, references/motion-language.md, and ../hyperframes-animation/. Use visual-design.md for required frame fields and the required ## Video direction block. Use composition.md for layout, hierarchy, focal points, and visual roles. Use motion-language.md and ../hyperframes-animation/ for valid effects and blueprint IDs. Do not invent effect names or blueprint IDs.
For every visual frame, add required visual and motion fields, including effects and focal and/or roles. Add one video-wide ## Video direction block for overall visual direction, motion style, pacing, and design rules.
Do not change story, script, asset choices, asset_candidates, transition_in, or captured source material. Do not write HTML in this step.
Stage named assets after visual design is locked:
node <SKILL_DIR>/scripts/stage-assets.mjs --storyboard ./STORYBOARD.md --hyperframes .
Gate: every visual frame has effects plus focal and/or roles; ## Video direction exists; assets/ contains the named assets.
---
Step 5: Build Frames
Goal: Build every storyboard frame as an HTML composition and assemble the playable video.
Wait for Step 3.1 audio to finish if audio was started. Then sync durations and fetch SFX; skip both if silent.
node <SKILL_DIR>/scripts/audio.mjs sync-durations --audio-meta ./audio_meta.json --storyboard ./STORYBOARD.md
node <SKILL_DIR>/scripts/audio.mjs fetch-sfx --storyboard ./STORYBOARD.md --hyperframes .
Duration sync is mechanical: real voice duration wins; silent frames keep estimates; never hand-edit synced durations.
Before dispatch, read sub-agents/frame-worker.md and ../hyperframes-core/references/subagent-dispatch.md. Dispatch one sub-agent per frame, in parallel if possible; otherwise run workers in waves. Each worker gets exactly one frame.
Each worker context must include PROJECT_DIR, frame_id, canvas size, caption status and keep-out band if captions are enabled, and ANIM_DIR as the absolute path to ../hyperframes-animation/. Each worker reads frame.md, its own ## Frame N block from STORYBOARD.md, and the recipe body for each cited effect or blueprint ID. Each worker writes only compositions/frames/NN-*.html. Workers must never edit STORYBOARD.md.
As each worker returns, the orchestrator marks that frame as animated in STORYBOARD.md.
After audio timings exist, build captions in the background and assemble the index:
node <SKILL_DIR>/scripts/captions.mjs build --storyboard ./STORYBOARD.md --audio-meta ./audio_meta.json --hyperframes . --out ./caption_groups.json &
node <SKILL_DIR>/scripts/assemble-index.mjs --storyboard ./STORYBOARD.md --hyperframes .
captions.mjs uses the project's caption-skin.html (copied in Step 2) as the caption look, injecting brand tokens from frame.md; with no skin present it renders the built-in default pill. captions: skipped (<reason>) is valid. Continue without captions when explicitly skipped.
Gate: every frame is marked animated, index.html exists, and captions are built or explicitly skipped.
---
Step 6: Finalize
Goal: Verify the assembled video, get user approval, and render the final MP4.
Inject transitions, run checks, pause for review, then render.
node <SKILL_DIR>/scripts/transitions.mjs inject --storyboard ./STORYBOARD.md --hyperframes .
node <SKILL_DIR>/scripts/transitions.mjs verify --storyboard ./STORYBOARD.md --index ./index.html
npx hyperframes lint
npx hyperframes validate
npx hyperframes inspect
npx hyperframes snapshot --at <frame-midpoints>
If a command fails, surface stderr and stop. Do not pile on recovery commands. If a gate names a frame, fix compositions/frames/NN-*.html with the cheapest safe fix: edit the frame HTML for a local issue; re-dispatch the frame worker only when the whole shot must be rebuilt.
Known false-positive — do not chase it. inspect may report a handful of text_box_overflow errors of ~1–4px on the caption highlight words (selector #caption-word-* / .caption-line). The caption pill uses a deliberately snug line-height (set once in scripts/captions.mjs) and has no `overflow:hidden`, so a heavy display glyph's ink spills a few px into the pill's own padding — nothing is actually clipped. Treat these as expected and proceed. Do not inflate the caption line-height (it balloons the pill, which is worse) and do not re-dispatch a frame for them. Only act on a text_box_overflow when it names a frame element (#el-NN-*), not a caption word.
After checks pass, pause for user review. The video is assembled, viewable, and editable in Studio. Manage preview only once across Step 3 and Step 6: open it if the user asked earlier, offer it if they declined earlier, and do not ask again if they are already reviewing in Studio.
Preview: npx hyperframes preview
Render only after user approval:
npx hyperframes render --quality high --output renders/video.mp4
Do not rerun lint, validate, inspect, or snapshot after rendering unless the user asks.
Gate: lint, validate, and inspect passed before render; user approved at the review pause; renders/video.mp4 exists. Final reply states MP4 path and final duration.
---
Quick Reference
Formats: landscape 1920x1080 by default; portrait 1080x1920; square 1080x1080. Set the format once in the storyboard frontmatter.
Background scripts: the workflow ships only these scripts under scripts/: build-frame for adopting + brand-remixing a frame preset into frame.md (+ caption skin); audio for TTS, transcription, BGM, SFX, and duration syncing; captions; transitions for inject and verify; stage-assets for copying frame-named assets into assets/; and assemble-index. Everything else is handled by the hyperframes CLI.
| Read | When |
|---|---|
[../hyperframes-creative/frame-presets/](../hyperframes-creative/frame-presets/) | Step 2: choose and adopt a frame preset. |
[../hyperframes-creative/references/design-spec.md](../hyperframes-creative/references/design-spec.md) | Step 2: apply brand tokens correctly. |
[references/story-design.md](references/story-design.md) | Step 3: plan the product-launch story. |
[../hyperframes-core/references/storyboard-format.md](../hyperframes-core/references/storyboard-format.md) | Step 3: write STORYBOARD.md. |
[../hyperframes-core/references/script-format.md](../hyperframes-core/references/script-format.md) | Step 3: write SCRIPT.md. |
[../hyperframes-media/references/tts.md](../hyperframes-media/references/tts.md) | Step 3.1: choose or understand TTS providers and voices. |
[references/visual-design.md](references/visual-design.md) | Step 4: enrich the storyboard visually. |
[references/composition.md](references/composition.md) | Step 4: judge composition. |
[references/motion-language.md](references/motion-language.md) | Step 4: judge motion language. |
[../hyperframes-animation/](../hyperframes-animation/) | Step 4: cite effect and blueprint IDs. |
[sub-agents/frame-worker.md](sub-agents/frame-worker.md) | Step 5: dispatch per-frame workers. |
[../hyperframes-core/references/subagent-dispatch.md](../hyperframes-core/references/subagent-dispatch.md) | Step 5: dispatch sub-agents safely. |
Composition — product-launch visual-design judgment
The composition-judgment layer for Step 4 (Visual design). You read it while enrichingSTORYBOARD.mdframes: which layout, how much frame the hero fills, how many depth layers — director decisions. Concrete px (safe margins 96-150), scale (1.05 / 0.92), three-layerbox-shadow,perspectivevalues are the frame worker's job; you name the intent in the frame's composition note. Video composition is closer to film / poster design than webpage layout — no scrolling, no reflow; every frame is a fixed canvas, every pixel matters. Default canvas 1920×1080; portrait1080×1920/ square1080×1080per the storyboardformat.
Squint test
Squint (or blur the frame). Can you still pick out the most important element, the second, and clear spatial groups? If everything has equal weight after blur, hierarchy is broken — redesign before writing the note. The strongest frames pass this: one dominant block + one supporting structural element, everything else demoted.
Canvas zones (conceptual)
+--------------------------------------------------+
| Optional top chrome |
| +----------------------------------------------+ |
| | Safe margin | |
| | +----------------------------------------+ | |
| | | Primary content area | | |
| | | (center 65-75% of frame) | | |
| | +----------------------------------------+ | |
| | | Caption band (bottom ~17%, HARD w/ captions) | |
| | +----------------------------------------+ | |
| +----------------------------------------------+ |
+--------------------------------------------------+- Top chrome — only for workspace-mockup frames; otherwise skip.
- Safe margin — key content stays off the edges; hero / editorial frames need more air.
- Primary content area — the center 65-75% is where the eye rests; body text never presses the edge.
- Caption band (bottom ~17%, HARD-reserved when captions are on) — when the film has captions enabled (the frame's
Captions:flag), the bottom ~17% of canvas height is reserved (landscape 1080h → bottom 180px, y 900-1080; portrait 1920h → bottom 320px, y 1600-1920): primary content and key visuals cap at the band top, and a centered hero anchors at y ≈ 0.42 × height (landscape ≈454, portrait ≈806), not the canvas midpoint. Background / ambient / surface layers are exempt and may stay full-bleed. Captions disabled → the zone is usable, but keep it clear anyway for bottom-edge consistency across frames.
You write "hero word centered with generous safe margins"; you do not write padding: 150px 120px 92px.
Portrait & square (non-16:9 canvases)
The zones, density, hierarchy, and depth principles all still apply; the aspect ratio changes, and a wide-frame layout does not transplant into a tall one. Design for the storyboard's format from the start — never plan landscape and "crop."
- Stack vertically, not side-by-side. Portrait has little horizontal room: split-screen / triptych / 60-40 asymmetry become top/bottom stacks, vertical step lists, stacked bands. Square tolerates side-by-side only for two compact items.
- Vertical center moves with the canvas — anchor a centered hero around y ≈ 0.42 × height (portrait ≈806, square ≈454), not a fixed 540.
- Type runs larger, fewer words per line — narrow frames wrap long headlines badly; prefer short kinetic lines, bigger type, more vertical rhythm.
- Travels well to portrait: Centered, Layered Depth, Full-Width Strip (now a stacked band), vertical Rule-of-Thirds. Avoid wide Split Screen and Triptych — use stacked equivalents.
- Density still rules — primary visual ≥ 40% of canvas, ≥ 3 depth layers, measured against the tall frame; an empty top or bottom third reads as placeholder.
- Real 16:9 screenshots don't fit a tall frame — never letterbox with dead bars, never stretch-distort. Instead: crop to the salient region; seat it as a top/bottom band with kinetic type filling the rest; or scale it inside a device / browser-frame mock.
7 composition templates
Use ≥3 different templates per video (5 frames → 3+, 9 frames → 4+). Don't default every frame to centered; never use the same layout class twice in a row.
1. Centered (hero / climax) — one dominant element, generous breathing room. Brand reveal, key metric, CTA, climax. 2. Rule of thirds — anchor on a thirds intersection; remaining space carries support or negative space. Feature showcase, demo + description. 3. Split screen (comparison / dual focus) — left/right halves carry separate elements. Before/after, comparison, problem/solution. 4. Layered depth (immersive) — foreground / midground / background differ in scale + opacity. Opening hooks, atmosphere, workbench beats. 5. Asymmetric (editorial) — primary content pushed to one side (60/40, 70/30); intentional imbalance → tension + sophistication. Feature focus, dense info, editorial proof. 6. Triptych (three-panel) — three equal zones for three capabilities / beats at once. 7. Full-width strip — one horizontal band (ticker, logo chain, marquee), usually ~20% of canvas height.
Frame density — avoid empty frames
Common failure: small elements floating in the center with empty space around them. Every frame must feel intentionally filled.
- Primary visual occupies ≥ 40% of canvas — hero text 50-75% height × 60-80% width; a centered card 30-50% × 50-70%.
- ≥ 3 visual layers — background (gradient / particles / grid) + midground (main content) + foreground (emphasis / decoration).
- Openings and closings are prone to emptiness — black background + a lonely line of text reads as placeholder. Add environmental layers: dual-radial swell, floating particles, brand-color ambient texture, low-opacity scanlines.
- Text-only frames still need visual elements — logo, an asset, icon decoration, halftone field, brand-derived geometry.
- Feature the frame's `asset_candidates` — story already chose the real assets for this frame (they're the highest-value material); a product screenshot at 60% + supporting text + an ambient layer feels full; the text alone feels empty.
Fullness test: could this frame stand as a poster or social graphic? If it looks like a sparse slide → add layers.
Negative space
Whitespace directs attention, it isn't waste. Tight grouping (icon + label) → small spacing; unrelated groups → large separation; asymmetric outer margins feel more designed than equal padding; a hero word keeps large side whitespace so one word carries the weight. Failure modes: everything equidistant (no grouping); unintended overlap; text tight against an edge; captions colliding with bottom visuals; the framework's default padding everywhere.
In-frame visual hierarchy
Visual weight, strong → weak: large image › motion (moving beats static) › high contrast › type scale › position (center + upper third are golden). Combine at least two — an element that is large, moving, and upper-third is unquestionably primary.
A title that is only _larger_ (sharing weight/color/spacing with body) reads weak. Stack dimensions:
| Dimension | Strong contrast |
|---|---|
| Size | 3:1 ratio or larger |
| Weight | 800-900 vs 400 |
| Color | high contrast against background |
| Motion | one element moving vs all else static |
| Position | top / left = primary |
| Space | large surrounding whitespace vs equidistant |
Cards and grouping
Spacing + alignment can group without a card container. Use cards when content is genuinely distinct, when a UI-demo group is independently actionable (command row, feature card), or when shadow-stacking communicates "lifted." Don't card for mere separation (use whitespace) or for a continuous list. Never nest cards — claustrophobic, muddy hierarchy. You write "comparison-split: left/right dual cards, three-layer shadow stack"; the worker writes the box-shadow.
Asset prominence
The frame's assets (logos, product images, screenshots) are real and strongly brand-related — feature them, don't shrink them into decoration. A focal product screenshot fills 40-60% of the frame; a logo stays recognizable at playback size; use the highest-quality version. Never replace a real asset with a generic decorative graphic when a real one exists.
Depth on a 2D canvas
Layer 2-3 depth techniques per frame to avoid a flat poster (concrete perspective / rotate / scale values are the worker's):
| Technique | Effect |
|---|---|
| Size difference | larger = nearer, smaller = farther |
| Blur | blurred = background, sharp = foreground |
| Opacity gradient | low = receding, full = primary |
| Overlap | foreground partially covers background |
| Shadow stacking | three-layer shadow = lift + brand feel |
| Motion speed | faster parallax = closer |
| Counter-scale | camera pushes toward focus → background appears larger, focal CSS scale <1 but fills frame |
You write "3 depth layers: background swell + midground product + foreground CTA glow; background counter-scales for the push"; the worker writes the scale values.
What should not appear
Nav bars, footers, cookie banners, scrollbars, cursor arrows, browser chrome, unclickable buttons, generic decorative shapes replacing real assets, floating bokeh / purple-to-blue AI gradients (the "default AI cliché," banned). Exception: an intentional product-interface reconstruction for a UI-demo frame — nav bars, command panels, timeline tracks, CTA buttons give real context, making it read as a real workflow.
Composition note example
"Composition: asymmetric 60/40 — product screenshot occupies left 60%, copy + CTA right 40%. Generous safe margin; text capped inside the primary content area. 3 depth layers: background swell + midground screenshot + foreground CTA glow. Density: primary visual ~55%, ambient adds 5% scanline + architectural grid."
One line per frame; never concrete px / scale / shadow recipes (the worker writes those).
Motion language — product-launch visual-design judgment
The motion-judgment layer for Step 4 (Visual design). You name each shot's choreography, spring intent, beat rhythm, holds, stillness, and the idle-motion budget while enrichingSTORYBOARD.mdframes; the frame worker maps intent to concrete GSAP eases / ms / stagger / code (viahyperframes-animation). A good promo feels like one continuous whole — one camera, one spring feel, every shot directed across its full length — not a pile of slides that animate once and freeze. You reference motion by role, never by curve: eases / durations resolve fromframe.md's motion tokens, namedentry/emphasis/exit/drift(the pack's exact keys may differ); the worker maps the curve. Between-frame transitions are not yours — story namestransition_in, the harness injects it.
A frame is a shot, not a slide
The single failure that makes a promo read as PowerPoint: a frame whose content animates in over the first ~0.8s, then freezes for the rest of its duration while a slow drift plays underneath. The entrance is not the shot — it's the first beat of it. You direct the whole duration.
Three layers fill a shot, each governed by a different rule:
| Layer | What | Rule |
|---|---|---|
| Camera (macro) | ONE correlated move on the frame root — slow drift / dolly / push / parallax pan | always on, the whole shot — this is the "someone is filming this" layer |
| Choreography (action) | the beat develops: entrance → mid-shot move (reveal / rearrange / morph / emphasis hit) → settle | fill the duration — a shot animated only at entry is a slide |
| Idle life (texture) | ambient continuation on the 1-2 elements that hold a live slot — breathing, glow, float | budgeted — this is where screensaver lives; cap it |
The reconciliation that matters: mandate choreography, budget idle life. Purposeful, sequential motion that carries information should fill the shot; ambient, simultaneous motion that carries none should be capped. Many elements each floating independently reads as _screensaver / UI animation_; a shot that only enters then freezes reads as _slideshow_. Avoid both — one camera move + a directed multi-phase action + 1-2 living elements, nothing scattered.
Multi-phase choreography — direct the full shot
Every non-still frame's timeline is choreographed across its length, not front-loaded into the entrance:
entrance → development → settle- entrance — the beat's primary content arrives (hero
entry/heavy; groups staggered). - development (the phase that's usually missing → PPT) — mid-shot, the content _does something_: a second element reveals, elements rearrange to a new layout, a card morphs to its next state, a count-up runs, an emphasis hit lands on the keyword. This is the motion that separates video from slides.
- settle — the shot resolves and holds for its read; the camera + idle life continue underneath (never a hard freeze).
Architecture: in hyperframes only the exit is forbidden mid-video (the frame unmounts; the harness transition _is_ the exit). Everything _before_ the settle — including rich mid-shot development — is free and seek-safe. Build the development phase; skip only the exit (unless you are the final frame).
When you name a `blueprint`, the development phases come from its recipe — write the composition note shot-by-shot to match. When you name no blueprint, the ≥3 cited effects ARE the phases — sequence them (one enters, one develops, one emphasizes); don't fire them all at t=0 (simultaneous entry collapses three phases into one slide).
Spring intent (by role, not curve)
| Intent | Feel | Use |
|---|---|---|
| entry | confident slight overshoot, settles quickly | primary element entry (default) |
| gentle | soft slide-in, no overshoot | background elements, subtle motion |
| snappy | tight overshoot, nearly instant | UI elements, small icons, buttons |
| heavy | weighted deceleration | large images, prototype screenshots, hero visuals |
| slam | bouncy overshoot, intentionally loud | logo / bell / impact moments |
Consistency: similar elements share one intent (all icons snappy, all hero images heavy). Don't invent a unique ease + duration per element.
Forbidden: bounce.out / elastic.out (dated; real objects decelerate, they don't bounce — low overshoot for entry is fine, high overshoot only for clearly playful moments); a unique ease+duration per element (visual noise).
Duration intent
Reference by tier ("instant feedback" / "state change" / "layout change" / "entry animation"); the worker maps concrete ms / frames at 30fps. A single entry should not exceed ~800ms — for a longer buildup, use multi-element stagger or a development phase, not one long tween.
Phase-to-phase within a shot is swift — when one element makes way for the next (development), the outgoing move runs ~75% of an entry; arrival is deliberate, hand-off is quick. (The between-frame exit is the harness's transition, never your within-shot motion.)
Stagger cap
When staggering N elements, total ≤ 500ms (longer feels dragged):
- 3-7 elements — normal stagger, total 300-700ms.
- 8+ elements — tighten per-item delay, or stagger only the first few and enter the rest with the last.
- Never let stagger run past 500ms.
Beat structure across frames (the cross-frame rhythm)
Rhythmic videos breathe: tension → release → tension → release. A clean reference shape for a ~46s video:
| Phase | Duration | Rhythm | Frame type |
|---|---|---|---|
| Slow setup | 6-10s | slow build | hero establish, VO not yet present |
| Fast montage | 6-10s | ~2s each | quick cuts every 1.5-2s |
| Process reveal | 12-18s | continuous, no cut | screen recording, real workflow |
| Closure | 3-5s | still, breathable | logo, URL, CTA |
Allocate motion by a frame's energy: high-energy (hook, CTA) → faster entry, tighter stagger, snappy, busier development; breathable (brand reveal, emotional beat) → slower entry, gentle, longer hold, minimal development; data (stat, feature) → medium rhythm, clean stagger, count-up as the development phase.
Hold time — read time, not freeze time
After an element enters it must stay long enough to read (the worker maps concrete frames). "Hold" means don't cut early — the camera + idle life keep playing underneath; it is never a hard freeze.
| Content | Minimum hold |
|---|---|
| display text (1-3 words) | ~1s |
| short sentence | ~1.5s |
| data / statistic | ~1.5s |
| product screenshot | ~2s |
| complex visual (diagram, comparison) | ~2.5s |
| hero / climax word | ~1-1.4s |
Narration shorter than the needed hold → the frame's duration should still give the visual its read time.
Stillness before climax — the marked exception
Archive signature: a 0.3-0.75s pause between the major action and its confirmation / result — the silence builds tension before the landing. It lands because the rest of the video is choreographed — stillness is a contrast against motion, so it only reads when motion is the baseline (if every frame is already frozen, a pause carries nothing). Allocate it to only 2-3 frames per video, named in the `## Video direction` block, where the narration lands a payoff. Stamped on every frame it becomes a tic and flattens the rhythm. Name stillness-before-climax in that frame's motion note; even then the camera move continues (still ≠ frozen).
The idle-life budget — what may move during the hold
The 1-2 elements that keep moving _after_ the development settles. This is the layer that, overdone, becomes screensaver — so it is capped, not mandated:
1. Camera move — always present (the macro layer above); it alone keeps everything coherently alive. 2. At most 1-2 secondary live elements — the ones carrying the beat (hero, CTA). Everything else holds. 3. Prefer macro move + depth parallax over many independent floats.
Secondary-slot menu (formulas are the worker's): multiplicative breathing (hero — small ±2-5% on final scale) · glow pulse (CTA / click target) · sine float (one decorative cluster at most) · rotational drift (3D cards, hero logo) · orbit (surrounding icons; counts as the one decorative cluster) · halftone breathing (atmospheric frames).
Multiplicative breathing is the signature for a hero that holds a live slot — not stamped on every hero. Minimum amplitude ±6px or ±2-5% scale — a 3px micro-float doesn't count; the budget caps how many things idle, it does not license invisible motion.
Seek-safe motion — intents that don't survive the renderer
The frame is a paused GSAP timeline seeked frame-by-frame, so some "continuous" intents from a real-time engine cannot render — don't name them:
- No infinite / forever motion — "particles loop endlessly," "logo rotates forever," "marquee scrolls on repeat." Idle life is a finite tween over the hold (breathe up then back), never
repeat/yoyo. - No randomness or wall-clock —
Math.randomparticle fields,Date.nowdrift. Every motion is the same on every render; name deterministic motion only. - Entrance + development only (exit = final frame only) — the cross-frame exit is the harness's transition.
- Express oscillation/breathing as a bounded finite move, not a loop; the worker writes it, but don't ask for a loop the timeline can't hold.
Forbidden — both failure modes
Slideshow (under-motion):
- Content animates in, then freezes for the rest of the shot (the PPT tell).
- Only the entrance is animated; the remaining duration is a frozen hold under a drift.
- The ≥3 cited effects all fire at t=0 instead of sequencing into entrance / development / emphasis.
- No mid-shot development on a non-still frame.
Screensaver (over-motion):
- Every element floating independently; idle motion with no information.
- More than 1-2 elements idling at once; scattered sine floats as the "aliveness."
- A 3px micro-float standing in for real motion.
Always:
bounce.out/elastic.out; a bespoke ease+duration per element;repeat/yoyo; all elements entering simultaneously (must stagger or sequence).
Motion note example
"Macro: slow dolly-in on the frame root across the whole beat. Entrance — hero enters EASE.entry (heavy); icons snappy-stagger (5 items, ~400ms). Development — icons rearrange into a row, then the result panel reveals beneath them. Stillness-before-climax 0.6s (allocated frame; only the dolly continues). Settle — result emphasis: text gentle entry + double-layer glow; idle hold with hero breathing ±3% as the one live element."One line for a single-shot frame; shot-by-shot when the beat is multi-phase (always, when you named a blueprint). Never concrete ease curves / ms / stagger formulas / JS — the worker writes those.
Story design — product launch video
Use this reference in Step 3 to write STORYBOARD.md and SCRIPT.md for a product launch, promo, feature reveal, or marketing video.
This file defines the story: what the video says, in what order, and why each frame exists. It does not define layout, visual effects, animation, or final markdown schemas. For exact file syntax, follow ../hyperframes-core/references/storyboard-format.md and ../hyperframes-core/references/script-format.md.
Read first
Read these inputs before writing:
1. hyperframes.json — locked brief: angle, length, aspect ratio, language. 2. frame.md — tone, mood, design system, and brand register. 3. capture/extracted/visible-text.txt — product facts, page copy, positioning, proof, CTA. 4. capture/extracted/asset-descriptions.md — the only source for captured asset inventory. 5. user_script.txt and VO_MODE, when present.
Do not inspect capture/assets/, contact sheets, screenshots, or raw captured files during Step 3. Treat asset-descriptions.md as the canonical asset list. Do not invent asset filenames.
Output
Create two files:
STORYBOARD.md— the narrative plan, one frame per beat.SCRIPT.md— the locked narration, only for spoken frames.
Every storyboard frame must include the required fields from the storyboard format reference, plus the narrative metadata below.
Core rule
A website is an information layout. A video is an emotional sequence.
Do not follow page order. Reorder, merge, omit, and reshape captured content into a clear launch story.
Step 3 method
1. Extract the product truth
From the brief and captured text, identify:
- Audience — who the video is speaking to.
- Pain or desire — what the viewer already wants fixed or achieved.
- Promise — the one-line thesis of the video.
- Product role — what the product does in the story.
- Proof — features, UI moments, metrics, logos, examples, or demos.
- CTA — what the viewer should do next.
Write the storyboard around the promise, not around a list of features.
2. Match the register to frame.md
Use frame.md as a soft guide:
frame.md signal | Story effect |
|---|---|
| restrained, editorial, B2B | plain, confident, low-hype script |
| bold, kinetic, launch-like | short hooks, punchier beats |
| warm, human, playful | friendly direct address, lighter phrasing |
| premium, cinematic, spectacle | aspirational framing, fewer words |
The product truth decides the arc. The visual system tunes the voice.
3. Choose one outer arc
Pick one primary arc. Use a compound only when useful, e.g. PAS with feature-benefit progression.
| Arc | Use when | Beat order | Reveal timing |
|---|---|---|---|
PAS | The pain is known and urgent. Best for broken B2B workflows. | hook → pain → agitation → solution tease → product intro → proof/demo → CTA | late |
Future Pacing | The product sells a new future, category, or paradigm. | imagine → name product → remove pain → show mechanism → show outcome → CTA | early |
Demo Loop | The UI is self-explanatory and the product is best shown working. | question → product intro → demo cycle 1 → demo cycle 2 → trust/benefit → CTA | early |
BAB | The product bridges an old workflow to a better one. | before → after tease → bridge/product → step 1 → step 2 → step 3/wow → CTA | early-mid |
Feature-Benefit Cascade | The product is feature-rich or desire/status-driven. | product/category hook → feature → benefit → feature → benefit → climax → CTA | frame 1 |
Use feature-benefit rhythm inside any arc when the video has many capabilities. Do not stack several features without translating them into viewer value.
4. Build the frame sequence
Each frame needs one clear job. Avoid frames that only say “more benefits” or “another feature.”
Use these frame types:
hook | pain_point | product_intro | feature_showcase | benefit_highlight | social_proof | branding | cta
For every frame, define:
type— one of the allowed frame types.persuasion— the specific persuasion move.beat— the viewer emotion.scene— a one-line visual idea, not detailed composition.voiceover— spoken guide text, or empty for silent frames.asset_candidates— real captured assets that could support the frame.
The prose under each frame should state:
narrativeRole— why this frame exists in the story.keyMessage— the one idea the viewer should remember.
Hook strategy
Pick one opening strategy for the first 3-5 seconds:
| Strategy | Use when |
|---|---|
| Shocking statistic | A credible number proves the pain. |
| Pain validation | The audience already knows the problem. |
| Rhetorical question | You need curiosity and speed. |
| Direct address | The audience is clear and specific. |
| Imagine / future pacing | The product introduces a new future. |
| Category announcement | The product is the category or launch headline. |
| Visual spectacle | The aesthetic is part of the pitch. |
| Trend positioning | The product rides a timely market shift. |
The hook must create tension, curiosity, or desire. Do not open with generic company description.
Persuasion labels
Use concrete labels. Do not write generic labels like “show benefit.”
Good labels include:
- Pain agitation
- Cognitive overload
- Negative contrast
- Friction reduction
- Simplification
- Show-don’t-tell proof
- Demonstration of capability
- Feature-to-benefit translation
- Statistical proof
- Authority by association
- Social proof
- Risk reversal
- Future pacing
- Value stacking
- Empowerment and control
- Rule of three
- Scarcity / urgency
- Status seeking
When no label fits, create one and explain the mechanism in the frame prose.
Emotional beats
Use specific emotions:
- Negative:
anxiety,frustration,overwhelm,tension,urgency,skepticism,FOMO - Pivot:
relief,curiosity,clarity,intrigue,aspiration - Build:
trust,confidence,control,ease,power,awe,excitement,belonging,reassurance - Close:
triumph,motivation,urgency-to-act,peace of mind,inevitability
Compound beats are allowed, e.g. relief + control.
Asset candidates
asset_candidates is the handoff from Step 3 to visual design and frame building.
Rules:
1. Read only capture/extracted/asset-descriptions.md to know what assets exist. 2. Use only filenames listed there. 3. Write candidates as assets/<basename>, where <basename> is the captured file basename. 4. Put all candidates on one line. 5. Separate candidates with semicolons. 6. Add a short description after —. 7. Prefer [video] assets when motion proves the product better than a still. 8. Use content assets: UI, screenshots, product photos, charts, diagrams, demos. 9. Skip tiny icons, favicons, badges, decorative chrome, and repeated logo variants unless the frame needs them. 10. Pure typography frames may use an empty asset list.
Example:
- asset_candidates: assets/dashboard-hero.png — main analytics UI, dark dashboard, wide screenshot; assets/demo-loop.mp4 — product interaction clip, query to result flowDo not use nested lists for assets. The parser expects a single metadata line.
UI demos
A UI demo should usually be a sequence, not one isolated frame.
Use 3 or more consecutive feature_showcase / benefit_highlight frames on the same product surface when the product value depends on workflow or interaction.
Good demo rhythm:
1. Input or user action. 2. Product response. 3. Result, insight, automation, or saved effort. 4. Benefit or trust proof.
Use consistent transitions across the sequence so it feels like one flow.
Script rules
If there is no pasted script
Write tight per-frame narration:
- 1-2 sentences per spoken frame.
- Usually 6-20 words per frame.
- Concrete and human.
- Say what the product does for a person.
- Prefer active verbs.
Avoid:
- “Seamless experience.”
- “Unlock the power of...”
- “Streamline your workflow.”
- Long noun-phrase lists.
- A whole frame with only “Or...” or another filler bridge.
Silent frames are allowed when the visual proves the point. Leave them out of SCRIPT.md.
If VO_MODE = restructure
Treat user_script.txt as source material. Rewrite, reorder, merge, or omit to fit the chosen arc and target length.
If VO_MODE = verbatim
Do not rewrite the user’s words. Segment the script into frame-sized chunks at sentence or paragraph boundaries. You may split a long sentence at a natural clause boundary, but do not change words. The final duration follows the provided script.
Transitions
Use only registry transition names:
cut | crossfade | blur-crossfade | push-slide LEFT | push-slide RIGHT | push-slide UP | push-slide DOWN | zoom-through | squeeze
Pick 2-3 transition types for the whole video and repeat them. Frame 1 can use cut as a placeholder.
Frame template
Use the exact fields required by the core storyboard format. This is the narrative shape each frame should satisfy:
## Frame N — Short name
- scene: one clear visual idea
- voiceover: "spoken guide text, or empty"
- duration: rough estimate in seconds
- transition_in: cut
- status: outline
- src: compositions/frames/NN-short-name.html
- type: hook
- persuasion: Pain validation
- beat: urgency
- asset_candidates: assets/example.png — short asset description
narrativeRole: What this frame does in the viewer journey.
keyMessage: The one idea the viewer should remember.Final checklist
Before asking for user approval, verify:
- The arc is named and consistent.
- The sequence is narrative-driven, not page-order-driven.
- The opening uses a clear hook strategy.
- Each frame has one job.
- Every frame has
type,persuasion, andbeat. - Every visual frame has suitable
asset_candidates, unless intentionally typography-only. - Asset filenames come only from
capture/extracted/asset-descriptions.md. - UI/product demos use a multi-frame sequence when needed.
- Transitions use only registry names and repeat 2-3 types.
SCRIPT.mdcontains only locked spoken narration.- Silent frames are intentional and omitted from
SCRIPT.md.
Visual design — product-launch per-frame enrichment method
The method behind Step 4 (Frame visual design). You (the orchestrator) read it to enrich `STORYBOARD.md` frames in place — story-design wrote the skeleton (each frame'sscene,voiceover,transition_in, the five narrative fields, and itsasset_candidates); you add how each frame looks and moves. Each frame is a directed shot, not a static slide — you choreograph it across its whole duration, not just its entrance. You write no HTML (that's the frame workers), you never read `capture/` (story already chose the assets), and you do not select assets or name transitions (story owns both).frame.mdis your palette/type truth. Composition / motion detail lives incomposition.md+motion-language.md; effect & blueprint bodies live inhyperframes-animation. Adding palette theory or a generic font rule here? Wrong home —frame.md+hyperframes-creative.
Every frame is a directed shot
A frame's visual layer is choreographed across its full duration, not front-loaded into an entrance. The failure that reads as PowerPoint: content animates in over the first ~0.8s, then freezes while a slow drift plays under it. So every frame's metadata + note describe a shot with phases — entrance → development → settle — where _development_ (a reveal, a rearrange, a morph, an emphasis hit, a count-up) is the mid-shot motion that separates video from slides. The shot model and the choreography-vs-idle budget live in motion-language.md; here you encode it into the frame: the effects / blueprint ids are the motion vocabulary, and the composition note sequences them into phases.
Deliberate stillness is the marked exception — the 2-3 climax/breather frames you allocate in ## Video direction. Every other frame develops; a held frame outside that allocation is just a slide.
What you add to each frame
Story-design's ## Frame N block already carries the narrative. You append the visual layer as frame metadata + one composition note (story's role/message prose stays):
## Frame 3 — The problem
- scene: a 20-minute timer spins on a stack of rejected takes ← refine only if it could read sharper
- voiceover: "…" ← story's; leave it
- transition_in: crossfade ← story's; leave it
- type: pain_point ← story's
- persuasion: Pain agitation
- beat: frustration
- effects: slow-push, count-up, vignette-pulse ← you add: cite effect ids (≥3, sequenced into the phases below)
- blueprint: messaging-multi-phase ← you add (optional): one multi-phase blueprint id
- focal: assets/timer-stack.png ← you add: which existing candidate is the hero
- roles: timer-stack = background (dim ~40%) ← you add: cutout / background / supporting per candidate
- sfx: impact-soft, riser ← you add: the sound the beat wants (fetched + mounted at root; never yours to embed)
Entrance: timer drops in upper-left (heavy), rejected-takes stack seated low. Development: the stack grows beat-by-beat toward the band as the reject count ticks up (count-up). Settle: vignette pulses in and holds; only the slow-push continues. A dense, edge-anchored frame.- `effects` — name atomic effect ids from
hyperframes-animation's rules index. Cite ≥3 when you name no `blueprint` (the worker composes them into the beat; fewer than 3 reads as generic motion); 1+ as accents when a blueprint already carries the choreography. With no blueprint, those ≥3 effects are the shot's phases — your note must sequence them (one enters, one develops, one emphasizes), not list them as a flat set that all fires at entry (that collapses three phases into one slide). The names are a shared vocabulary; the recipe lives there — you cite, the worker reads the body and reproduces it (not a name-guess). - `blueprint` — name one multi-phase blueprint id from
hyperframes-animation/blueprints-index.mdwhen a frame's beat wants a proven multi-phase shape (multi-phase reveal, orbit-collapse, …). Two postures — both require the worker to read the recipe body (and run its `examples/<id>.html` to see the signature move) first; deviating from a name-guess instead of from understanding is the one banned failure: - Reproduce — the blueprint fits the beat cleanly and the frame's content maps onto its slots; the worker reproduces its phases faithfully. Write the composition note shot-by-shot to match.
- Adapt — the blueprint is the right _structure_ but the content / beat / asset-count doesn't fit its exact form, or you want the proven skeleton with a fresher surface (anti-templating), or you're grafting a second blueprint's phase. Lead the note with a `Base / Keep / Depart` line —
Base:the blueprint id ·Keep:its signature (the move that makes it itself — the SVG ring, the orbit→collapse; never drop this, or you named the wrong blueprint) ·Depart:what you change and why. Adapt may extend or vary, never reduce below the shot model — never flatten a multi-phase blueprint into a single entrance. E.g.Base: avatar-cloud-network · Keep: the SVG-connected elliptical ring · Depart: 7 logos not 12, + a hub count-up as the development beat.
Choose Reproduce when the shape fits as-is, Adapt when the structure fits but the form doesn't (or to avoid templating); omit blueprint entirely when no blueprint's structure fits — then the cited effects (≥3) carry the phases (Compose).
- `focal` / `roles` — story listed
asset_candidates; you pick the focal hero and each candidate's role (cutout= foreground subject, lay text around it;background= full-bleed, dim 30-50%; supporting = secondary). You consume the candidates — never add, swap, or drop one (coverage is story's call; if a frame truly has the wrong candidates, flag it back, don't reach intocapture/). - `sfx` — name the sound the beat wants (an impact for a slam, a whoosh for a push). The audio script's
fetch-sfxpass retrieves it from HeyGen and the assembler mounts it at the root — you only name it, never embed an<audio>element. - composition note — the frame's visual brief: layout, hero, depth layers, the macro move, and the shot's phases. Default to a phased note —
entrance: … → development: … → settle: …(shot-by-shot; mandatory when you named ablueprint) — naming what's on screen and what moves in each phase, so the worker builds the development instead of freezing after entry. A single still line is correct only for a deliberately held climax or an allocated stillness frame. Full method →composition.md(layout) +motion-language.md(phases).
Video direction — write the invariants ONCE
The whole video shares one look and one motion grammar. State it once, at the top of STORYBOARD.md (a ## Video direction block), so every frame inherits it and per-frame metadata carries only the delta:
- palette system — from
frame.md: which roles map to which hues. Never invent. - motion defaults + shot model — default eases + the choreography baseline (every frame a directed shot: entrance → development → settle) + the idle-life budget (what may keep moving during the hold) (→
motion-language.md). - negative list — what never appears: off-brand textures and effects the pack forbids, plus both motion failure modes — slideshow (enter-then-freeze) and screensaver (everything floating independently) (→
motion-language.md). - stillness allocation — name the 2-3 frames that hold still before a climax; every other frame develops (the anti-repetition discipline; →
motion-language.md).
Do not repeat these in every frame — restating video-level rules per frame is exactly the bloat this layer prevents. Each frame's metadata is the delta on top of Video direction.
Palette & type — from frame.md, never invented
- Palette —
frame.md(the adopted pack) is the color truth; apply its roles per frame. Generic basics (one accent, tint neutrals, avoid pure#000/#fff) →hyperframes-creative/references/house-style.md. - Type — fonts resolve via
frame.md's type tokens; reference them by role (display / body / mono / the pack's ramp), never by raw family or px. Generic typography craft (embedded fonts, dark-bg optical compensation,tabular-nums) →hyperframes-creative/references/typography.md.
Caption-band keep-out (plan side)
The bottom ~17% of the canvas is reserved for the caption pill. Plan every frame's content into the top ~83% so nothing important lands in the band (the worker enforces the pixel cutoff; you plan the layout). Holds even when captions are disabled — bottom-edge consistency. Geometry detail → composition.md.
Where the detail lives
| For… | Read |
|---|---|
| composition — zones, density, templates, asset prominence, caption geometry | composition.md (local) |
| motion — the shot model, phases, idle budget, beat structure, stillness | motion-language.md (local) |
| effect ids + blueprint ids (vocabulary + recipes) | ../hyperframes-animation/blueprints-index.md + ../hyperframes-animation/rules-index.md |
| palette + type tokens | the project's frame.md; basics → hyperframes-creative house-style.md / typography.md |
| "produced, not generated" foreground density | hyperframes-creative/references/video-composition.md |
| transitions | story-design owns transition_in; you don't touch it |
Before you finish — checklist
- Every frame has
effects(≥1 cited id; ≥3 when no `blueprint` is named); ablueprintwhere the frame matches one, with a shot-by-shot composition note. - Every frame's composition note is phased (entrance → development → settle / shot-by-shot) — not a single entry that then freezes; the ≥3 effects are sequenced across phases, not all fired at t=0.
- Stillness is only the 2-3 frames allocated in Video direction; every other frame develops mid-shot.
- Each visual frame's
asset_candidateshave afocal+ per-candidateroles; none added or dropped. - Video direction stated once at the top (palette · shot model + idle budget · negative list incl. both failure modes · stillness allocation); per-frame entries are deltas, not restatements.
- Content planned into the top ~83% (caption band clear).
- Palette / type pulled from
frame.mdby role — nothing invented. - You wrote no HTML and never read
capture/.
#!/usr/bin/env node
// assemble-index.mjs — deterministic top-level index.html assembly for a
// product-launch project. No subagent, no judgment: turns STORYBOARD.md + the
// built frame files (+ optional audio_meta.json) into the standalone index.html
// the renderer consumes, and stages the frame-named capture assets into assets/.
//
// index.html is a *standalone* composition (root <div id="root"> directly in
// <body>, no <template> wrapper — template is for sub-comps). Structure is
// modeled on the canonical fixture packages/studio/fixtures/storyboard-sample/
// index.html and the authoritative head/audio template in
// packages/core/docs/quickstart-template.html. Frame mount order = STORYBOARD
// document order. Transitions are NOT written here — the transitions injector
// mutates this file afterward (data-start/duration/track-index + GSAP).
//
// Track lanes (same-track time-overlap is illegal — lint timeline_track_too_dense):
// 1 frame sub-comp clips (sequential; the injector 0/1-ping-pongs for overlaps)
// 2 captions sub-comp clip (full-duration overlay, on top of frames)
// 10 per-frame voice <audio>
// 11 BGM <audio>
// 20+i SFX <audio> (one lane each)
//
// audio_meta.json contract (produced by audio.mjs; OPTIONAL — absent ⇒ silent
// video, frames only). Durations come from STORYBOARD (audio sync-durations
// writes them), NOT from here; this file carries only media PATHS, keyed by
// frame number:
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.8 } | null,
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
// "duration_s": 1.0, "volume": 0.35 } ] }
//
// Reads: --storyboard STORYBOARD.md, --hyperframes <project root>,
// [--audio-meta audio_meta.json]. On disk: each built frame's src html,
// capture/{assets,assets/videos,screenshots}/<basename> for staging, compositions/captions.html.
// Writes: <project>/index.html + stages assets/<basename>.
//
// Exit 0 = index.html written + summary. Exit 1 = fatal contract break (no
// frames, a built/animated frame missing its src/file, a frame with no
// duration, an inner data-composition-id mismatch). No backstop: fix upstream.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs";
import { stageAssets } from "./lib/assets.mjs";
import { parseColors, semanticColors } from "./lib/tokens.mjs";
// ---------- argv ----------
const argv = process.argv.slice(2);
const flag = (name, def) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};
function die(msg) {
console.error(`✗ assemble-index.mjs: ${msg}`);
process.exit(1);
}
const hyperframesDir = resolve(flag("hyperframes", "."));
const storyboardPath = resolve(flag("storyboard", join(hyperframesDir, "STORYBOARD.md")));
const audioMetaPath = resolve(flag("audio-meta", join(hyperframesDir, "audio_meta.json")));
const outPath = resolve(flag("out", join(hyperframesDir, "index.html")));
const r3 = (x) => Math.round(x * 1000) / 1000;
const anomalies = [];
// ---------- parse storyboard ----------
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
const { width: WIDTH, height: HEIGHT } = parseFormat(manifest.globals.format);
// ---------- resolve mountable frames in document order ----------
// A frame mounts when its src html exists on disk. A built/animated frame
// missing its src/file is a contract break (die). An outline frame with no
// file is skipped (still a placeholder) with an anomaly note.
const mounted = [];
for (const f of manifest.frames) {
const label = `frame ${f.number ?? f.index}${f.title ? ` (${f.title})` : ""}`;
const built = f.status === "built" || f.status === "animated";
if (!f.src) {
if (built) die(`${label} is ${f.status} but has no \`src\` — the orchestrator must write it`);
anomalies.push(`${label}: status ${f.status}, no src — skipped`);
continue;
}
const compAbs = join(hyperframesDir, f.src);
if (!existsSync(compAbs)) {
if (built)
die(`${label} is ${f.status} but its src ${f.src} is not on disk — re-dispatch the worker`);
anomalies.push(`${label}: src ${f.src} not on disk (status ${f.status}) — skipped`);
continue;
}
if (!Number.isFinite(f.durationSeconds) || f.durationSeconds <= 0) {
die(
`${label}: no positive duration (got ${JSON.stringify(f.duration)}) — run audio sync-durations`,
);
}
// Host data-composition-id MUST equal the inner file's, or the runtime never
// finds the timeline. frame_id = src basename (frame-worker contract); verify
// the inner html actually declares it.
const compId = basename(f.src).replace(/\.html?$/i, "");
const html = readFileSync(compAbs, "utf8");
// Guard against blank/partial scene files: a worker that errors or is
// interrupted mid-write leaves an empty (or markup-less) file that exists but
// fails at render with "Composition HTML is empty or could not be parsed".
// Catch it here — before emitting data-composition-src — and re-dispatch.
if (!html.trim() || !/<\w/.test(html)) {
die(
`${label}: ${f.src} is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.`,
);
}
if (
!html.includes(`data-composition-id="${compId}"`) &&
!html.includes(`data-composition-id='${compId}'`)
) {
die(`${label}: ${f.src} has no data-composition-id="${compId}" (host/inner id must match)`);
}
mounted.push({ frame: f, compId, durationSeconds: r3(f.durationSeconds) });
}
if (mounted.length === 0) die("no mountable frames (none built with an on-disk src)");
// cumulative starts — emitted data-start[i] + data-duration[i] == start[i+1] by
// construction (renderer computes end the same way), so adjacent clips touch
// exactly with no float-overlap.
let acc = 0;
for (const m of mounted) {
m.start = acc;
acc += m.durationSeconds;
}
const TOTAL = r3(acc);
const startOfFrameNumber = new Map();
for (const m of mounted) if (m.frame.number != null) startOfFrameNumber.set(m.frame.number, m);
// ---------- audio_meta (optional) ----------
let audio = { bgm: null, voices: [], sfx: [] };
if (existsSync(audioMetaPath)) {
try {
const parsed = JSON.parse(readFileSync(audioMetaPath, "utf8"));
audio = { bgm: parsed.bgm ?? null, voices: parsed.voices ?? [], sfx: parsed.sfx ?? [] };
} catch (e) {
die(`audio_meta.json parse: ${e.message}`);
}
}
const voiceByFrame = new Map();
for (const v of audio.voices) if (v.frame != null) voiceByFrame.set(v.frame, v);
// ---------- build <body> in track order ----------
const body = [];
let voiceCount = 0;
for (const m of mounted) {
// (track 1) frame sub-comp clip — no class="clip" semantics needed; .scene CSS sizes it.
body.push(
` <div`,
` id="el-${m.compId}"`,
` class="scene"`,
` data-composition-id="${m.compId}"`,
` data-composition-src="${m.frame.src}"`,
` data-start="${m.start}"`,
` data-duration="${m.durationSeconds}"`,
` data-track-index="1"`,
` ></div>`,
);
// (track 10) voice — only when the file is actually on disk.
const v = m.frame.number != null ? voiceByFrame.get(m.frame.number) : undefined;
if (v?.path) {
if (existsSync(join(hyperframesDir, v.path))) {
body.push(
` <audio`,
` id="el-${m.compId}-voice"`,
` src="${v.path}"`,
` data-start="${m.start}"`,
` data-duration="${m.durationSeconds}"`,
` data-track-index="10"`,
` data-volume="1"`,
` ></audio>`,
);
voiceCount++;
} else {
anomalies.push(`${m.compId}: voice ${v.path} not on disk — skipped`);
}
}
body.push("");
}
// (track 11) BGM — duck under narration when any voice is present.
let bgmEmitted = false;
if (audio.bgm?.path) {
if (existsSync(join(hyperframesDir, audio.bgm.path))) {
const vol = audio.bgm.volume != null ? audio.bgm.volume : voiceCount > 0 ? 0.8 : 0.9;
body.push(
` <!-- BGM -->`,
` <audio`,
` id="el-bgm"`,
` src="${audio.bgm.path}"`,
` data-start="0"`,
` data-duration="${TOTAL}"`,
` data-track-index="11"`,
` data-volume="${vol}"`,
` ></audio>`,
"",
);
bgmEmitted = true;
} else {
anomalies.push(`bgm ${audio.bgm.path} not on disk — skipped`);
}
}
// (track 2) captions — captions.mjs writes this or legally skips; key off existence.
let captionsEmitted = false;
if (existsSync(join(hyperframesDir, "compositions/captions.html"))) {
body.push(
` <!-- captions -->`,
` <div`,
` id="el-captions"`,
` class="scene"`,
` data-composition-id="captions"`,
` data-composition-src="compositions/captions.html"`,
` data-start="0"`,
` data-duration="${TOTAL}"`,
` data-track-index="2"`,
` ></div>`,
"",
);
captionsEmitted = true;
}
// (track 20+i) SFX — placed at its frame's start + offset.
let sfxEmitted = 0;
audio.sfx.forEach((cue, i) => {
const host = cue.frame != null ? startOfFrameNumber.get(cue.frame) : undefined;
if (!host) {
anomalies.push(`sfx ${cue.file}: frame ${cue.frame} not mounted — skipped`);
return;
}
const rel = cue.file;
if (!existsSync(join(hyperframesDir, rel))) {
anomalies.push(`sfx ${rel} not on disk — skipped`);
return;
}
const t = r3(host.start + (cue.offset_s ?? 0));
const dur = r3(cue.duration_s ?? 1);
const vol = cue.volume != null ? cue.volume : 0.35;
if (sfxEmitted === 0) body.push(` <!-- SFX -->`);
body.push(
` <audio`,
` id="el-sfx-${i}"`,
` src="${rel}"`,
` data-start="${t}"`,
` data-duration="${dur}"`,
` data-track-index="${20 + i}"`,
` data-volume="${vol}"`,
` ></audio>`,
);
sfxEmitted++;
});
// ---------- stage frame-named assets: capture/ → assets/ (idempotent backstop) ----------
// Frame workers + the live preview reference assets/<basename>; stage-assets.mjs
// already ran this at Step 4 close. Re-run as a backstop so a late-named asset
// still lands. Shared logic: lib/assets.mjs (first-wins, safe to call twice).
const {
staged,
wanted,
anomalies: assetAnomalies,
} = stageAssets({
hyperframesDir,
frames: manifest.frames,
});
for (const a of assetAnomalies) anomalies.push(a);
// ---------- <head> ----------
// ---------- ground color ----------
// Per-frame roots carry data-start/data-duration and get clip-gated against the
// global timeline in render (only the first frame's [0,dur] window overlaps global
// 0), so a frame's own full-bleed background can't be relied on as the video ground —
// every frame after the first would render on the bare body color (black). Paint the
// ground on the always-present root composition instead, using the project's frame.md
// canvas color (the same ground role the caption skin maps to --cap-canvas). Falls
// back to the body letterbox color when frame.md is absent or has no resolvable ground.
const framePath = join(hyperframesDir, "frame.md");
let groundColor = null;
if (existsSync(framePath)) {
try {
const roles = semanticColors(parseColors(readFileSync(framePath, "utf8")));
if (roles && roles.canvas) groundColor = roles.canvas;
} catch {
/* leave groundColor null — #root stays transparent over the body letterbox */
}
}
const headStyle = [
" * {",
" margin: 0;",
" padding: 0;",
" box-sizing: border-box;",
" }",
" html,",
" body {",
` width: ${WIDTH}px;`,
` height: ${HEIGHT}px;`,
" overflow: hidden;",
" background: #000;",
" }",
" #root {",
" position: relative;",
` width: ${WIDTH}px;`,
` height: ${HEIGHT}px;`,
" overflow: hidden;",
...(groundColor ? [` background: ${groundColor};`] : []),
" }",
" .scene {",
" position: absolute;",
" inset: 0;",
" width: 100%;",
" height: 100%;",
" }",
].join("\n");
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=${WIDTH}, height=${HEIGHT}" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
${headStyle}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="${TOTAL}"
data-width="${WIDTH}"
data-height="${HEIGHT}"
>
${body.join("\n")}
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body>
</html>
`;
writeFileSync(outPath, html);
// ---------- summary ----------
console.log(`✓ wrote ${outPath}`);
console.log(` canvas: ${WIDTH}×${HEIGHT}`);
console.log(` frames (track 1): ${mounted.length}`);
console.log(` voice (track 10): ${voiceCount}`);
console.log(` bgm (track 11): ${bgmEmitted ? "yes" : "no"}`);
console.log(` captions (track 2): ${captionsEmitted ? "yes" : "no"}`);
console.log(` sfx (track 20+): ${sfxEmitted}`);
console.log(` assets staged: ${staged}/${wanted.size}`);
console.log(` total duration: ${TOTAL}s`);
if (anomalies.length) {
console.log(`\nanomalies (non-fatal):`);
for (const a of anomalies) console.log(` - ${a}`);
}
#!/usr/bin/env node
// audio.mjs — product-launch audio ADAPTER. The TTS / BGM / SFX implementation
// no longer lives here: it is the shared engine at
// ../../hyperframes-media/scripts/audio.mjs. This file only (a) maps the
// product-launch model (SCRIPT.md frames + STORYBOARD.md music/sfx) into the
// engine's neutral audio_request.json, (b) converts the engine's id-keyed
// audio_meta back into the frame-keyed shape captions.mjs / assemble-index.mjs
// already consume, and (c) keeps the local `sync-durations` pass (it rewrites
// STORYBOARD.md, which is product-launch-specific).
//
// Three modes (unchanged CLI surface):
// (default) generate — engine --only tts,bgm. BGM mode is "retrieve" (strict:
// no HeyGen credential ⇒ skip, never a detached generate, since this
// workflow has no wait-bgm step). Runs in the background during Step 4.
// sync-durations — write real voice durations into STORYBOARD.md (local).
// fetch-sfx — engine --only sfx, merged into the existing meta (Step 5,
// after the frames' `sfx:` cues exist).
//
// node audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json
// node audio.mjs sync-durations --audio-meta ./audio_meta.json --storyboard ./STORYBOARD.md
// node audio.mjs fetch-sfx --storyboard ./STORYBOARD.md --hyperframes .
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseStoryboard } from "./lib/storyboard.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ENGINE = join(HERE, "..", "..", "hyperframes-media", "scripts", "audio.mjs");
const flag = (argv, name, def) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};
const pad2 = (n) => String(n).padStart(2, "0");
// SCRIPT.md → [{ frame, text }]. `## … (Frame N)` opens a line; `**key:**` rows
// are metadata; the indented block is the spoken text (the only TTS input).
function parseScript(md) {
const out = [];
let cur = null;
const flush = () => {
if (cur && cur.text.trim()) out.push({ frame: cur.frame, text: cur.text.trim() });
cur = null;
};
for (const line of md.split(/\r?\n/)) {
const h = line.match(/^#{2,3}\s+.*?\(frame\s+(\d+)\)/i);
if (h) {
flush();
cur = { frame: Number(h[1]), text: "" };
continue;
}
if (!cur) continue;
if (/^\s*\*\*/.test(line)) continue;
const m = line.match(/^(?: {4,}|\t)(.+)$/);
if (m) cur.text += (cur.text ? " " : "") + m[1].trim();
}
flush();
return out;
}
// Path of the engine's neutral meta — a stable sidecar so `--only` merges
// (generate then fetch-sfx) accumulate, while audio_meta.json holds the PL shape.
const neutralPath = (plOutPath) => join(dirname(plOutPath), "audio_engine_meta.json");
// Run the shared engine. Returns nothing; dies on a non-zero exit.
function runEngine({ request, hyperframesDir, neutral, only, extra = [] }, die) {
const reqPath = join(hyperframesDir, "audio_request.json");
writeFileSync(reqPath, JSON.stringify(request, null, 2));
const engine = process.env.HF_MEDIA_ENGINE || DEFAULT_ENGINE;
if (!existsSync(engine)) die(`media audio engine not found at ${engine} (set $HF_MEDIA_ENGINE)`);
const args = [
engine,
"--request",
reqPath,
"--hyperframes",
hyperframesDir,
"--out",
neutral,
"--only",
only,
...extra,
];
const r = spawnSync("node", args, { stdio: "inherit" });
if (r.status !== 0) die(`media audio engine exited ${r.status}`);
}
// Engine neutral meta (id-keyed) → product-launch meta (frame-keyed) consumed by
// captions.mjs / assemble-index.mjs. id is the zero-padded frame number.
function toProductLaunchMeta(neutral) {
const voices = (neutral.voices ?? []).map((v) => ({
frame: Number(v.id),
path: v.path,
duration_s: v.duration_s,
words: (v.words ?? []).map((w) => ({ id: w.id, text: w.text, start: w.start, end: w.end })),
}));
const bgm = neutral.bgm
? {
path: neutral.bgm.path,
volume: neutral.bgm.volume,
query: neutral.bgm.query ?? null,
duration_s: neutral.bgm.duration_s ?? null,
}
: null;
const sfx = (neutral.sfx ?? []).map((s) => ({
frame: Number(s.id),
file: s.file,
offset_s: s.offset_s ?? 0,
duration_s: s.duration_s ?? 1,
volume: s.volume ?? 0.35,
}));
return { bgm, voices, sfx };
}
// ── generate (TTS + BGM) ────────────────────────────────────────────────────
function runGenerate(argv) {
const die = (m) => {
console.error(`✗ audio generate: ${m}`);
process.exit(1);
};
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
const scriptPath = resolve(flag(argv, "script", join(hyperframesDir, "SCRIPT.md")));
const outPath = resolve(flag(argv, "out", join(hyperframesDir, "audio_meta.json")));
const userVoice = flag(argv, "voice", null);
const speed = Number(flag(argv, "speed", "1.0")) || 1.0;
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
const g = manifest.globals;
const lines = existsSync(scriptPath)
? parseScript(readFileSync(scriptPath, "utf8")).map((l) => ({
id: pad2(l.frame),
text: l.text,
}))
: [];
if (!lines.length) console.error("· no SCRIPT.md — silent film (BGM only)");
// BGM mood: storyboard `music:` → message → arc → default. `mode: retrieve` is
// strict here (no wait-bgm step downstream).
const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore";
const request = {
provider: "auto",
speed,
lines,
bgm: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" },
};
if (userVoice) request.voice = userVoice;
const neutral = neutralPath(outPath);
runEngine({ request, hyperframesDir, neutral, only: "tts,bgm" }, die);
const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8")));
writeFileSync(outPath, JSON.stringify(meta, null, 2));
console.log(
`✓ audio generate: ${meta.voices.length} voice + ${meta.bgm ? "1 bgm" : "no bgm"} → ${outPath}`,
);
}
// ── fetch-sfx ────────────────────────────────────────────────────────────────
function runFetchSfx(argv) {
const die = (m) => {
console.error(`✗ audio fetch-sfx: ${m}`);
process.exit(1);
};
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
const outPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json")));
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
// Per-frame `sfx:` cues (comma-separated) → engine lines carrying only sfx.
const lines = [];
for (const f of manifest.frames) {
const names = (f.extra?.sfx ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (names.length && f.number != null) lines.push({ id: pad2(f.number), sfx: names });
}
const neutral = neutralPath(outPath);
const request = { lines, bgm: { mode: "none" } };
// --only sfx is a MERGE, not an overwrite: the engine reads the existing neutral
// sidecar (audio_engine_meta.json) and recomputes only the sfx section, so the
// voices/bgm written by the earlier generate (--only tts,bgm) pass are preserved.
runEngine({ request, hyperframesDir, neutral, only: "sfx" }, die);
const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8")));
writeFileSync(outPath, JSON.stringify(meta, null, 2));
console.log(`✓ audio fetch-sfx: ${meta.sfx.length} SFX cue(s) → ${outPath}`);
}
// ── sync-durations (local; rewrites STORYBOARD.md) ────────────────────────────
function runSyncDurations(argv) {
const die = (m) => {
console.error(`✗ audio sync-durations: ${m}`);
process.exit(1);
};
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
const audioMetaPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json")));
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
if (!existsSync(audioMetaPath)) die(`audio_meta.json not found at ${audioMetaPath}`);
const meta = JSON.parse(readFileSync(audioMetaPath, "utf8"));
const durByFrame = new Map();
for (const v of meta.voices ?? []) {
if (v.frame != null && v.duration_s) durByFrame.set(v.frame, v.duration_s);
}
// Read directly and handle ENOENT here, rather than an existsSync precheck —
// the check→write pair (write-back below) is a TOCTOU race CodeQL flags.
let storyboardRaw = "";
try {
storyboardRaw = readFileSync(storyboardPath, "utf8");
} catch {
die(`STORYBOARD.md not found at ${storyboardPath}`);
}
const lines = storyboardRaw.split(/\r?\n/);
const FRAME_RE = /^#{2,3}\s+(?:frame|beat|scene)\b.*?(\d+)/i;
let curFrame = null;
let updated = 0;
for (let i = 0; i < lines.length; i++) {
const h = lines[i].match(FRAME_RE);
if (h) {
curFrame = Number(h[1]);
continue;
}
if (curFrame != null && durByFrame.has(curFrame)) {
const m = lines[i].match(/^(\s*[-*]\s+duration\s*:\s*).*/i);
if (m) {
lines[i] = `${m[1]}${durByFrame.get(curFrame)}s`;
durByFrame.delete(curFrame);
updated++;
}
}
}
writeFileSync(storyboardPath, lines.join("\n"));
const missing = [...durByFrame.keys()];
console.log(
`✓ audio sync-durations: ${updated} frame duration(s) updated` +
(missing.length ? ` · no \`- duration:\` line for frame(s) ${missing.join(", ")}` : ""),
);
}
// ── dispatch ──────────────────────────────────────────────────────────────────
const sub = process.argv[2];
if (sub === "sync-durations") runSyncDurations(process.argv.slice(3));
else if (sub === "fetch-sfx") runFetchSfx(process.argv.slice(3));
else runGenerate(process.argv.slice(2)); // default: generate
#!/usr/bin/env node
// build-frame.mjs — Step 2 design system in ONE command. The LLM only chooses a
// preset; this does the deterministic rest: copy the preset's FRAME.md → frame.md,
// remix its colors/typography onto the project's brand tokens, copy the preset's
// caption-skin.html, and self-validate. "Strict on brand" is deterministic, so it's
// a script, not LLM hand-editing (which mis-copies hex / breaks keys).
//
// node build-frame.mjs --preset capsule --hyperframes .
// [--tokens capture/extracted/tokens.json] [--preset-dir <abs path to frame-presets>]
//
// Remix rule — ONLY `colors:` values and `typography:` fontFamily change; keys,
// structure, geometry, and components are untouched:
// colors — map brand tokens onto the preset's keys BY ROLE: the ink-role key takes
// the brand ink (darkest/ink-named), the canvas-role key takes the brand
// canvas (lightest), and every other color is repainted with the nearest
// brand accent's hue+saturation while KEEPING its own lightness, so tint
// families (sun / sun-soft / haze) stay a family. Empty brand colors → the
// preset palette is kept (it is already a complete, good design).
// fonts — the preset's display family → the brand display font, its body family →
// the brand body font, wherever they appear. Empty brand fonts → kept.
import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
brandRolesFromStats,
chroma,
lum,
parseColors,
parseFonts,
pickAccent,
semanticColors,
UA_DEFAULT_COLORS,
} from "./lib/tokens.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const flag = (name, def) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};
const die = (m) => {
console.error(`✗ build-frame: ${m}`);
process.exit(1);
};
const presetName = flag("preset", null);
const hyperframesDir = resolve(flag("hyperframes", "."));
const presetDir = resolve(
flag("preset-dir", join(__dirname, "../../hyperframes-creative/frame-presets")),
);
const tokensPath = resolve(flag("tokens", join(hyperframesDir, "capture/extracted/tokens.json")));
if (!presetName) die("--preset <name> is required");
const presetFrame = join(presetDir, presetName, "FRAME.md");
if (!existsSync(presetFrame)) {
const avail = existsSync(presetDir)
? readdirSync(presetDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
: [];
die(
`no FRAME.md for preset "${presetName}" under ${presetDir}\n available: ${avail.join(", ")}`,
);
}
// ── HSL helpers (recolor = brand hue+sat, original lightness) ──────────────────
function hexToHsl(hex) {
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex).trim());
if (!m) return null;
const n = parseInt(m[1], 16);
const r = ((n >> 16) & 255) / 255,
g = ((n >> 8) & 255) / 255,
b = (n & 255) / 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b),
d = max - min;
let h = 0;
const l = (max + min) / 2;
const s = d === 0 ? 0 : l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (d !== 0) {
h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
h *= 60;
}
return { h, s, l };
}
function hslToHex(h, s, l) {
h = (((h % 360) + 360) % 360) / 360;
const hue = (p, q, t) => {
t = (t + 1) % 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue(p, q, h + 1 / 3);
g = hue(p, q, h);
b = hue(p, q, h - 1 / 3);
}
const to = (x) =>
Math.round(x * 255)
.toString(16)
.padStart(2, "0")
.toUpperCase();
return `#${to(r)}${to(g)}${to(b)}`;
}
const hueDist = (a, b) => {
const d = Math.abs(a - b) % 360;
return d > 180 ? 360 - d : d;
};
// ── brand tokens ──────────────────────────────────────────────────────────────
let brandColors = [];
let brandFonts = [];
let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
if (existsSync(tokensPath)) {
try {
const t = JSON.parse(readFileSync(tokensPath, "utf8"));
brandColors = (t.colors ?? [])
.map((c) => (typeof c === "string" ? c : (c?.hex ?? c?.value ?? "")))
.map((c) => String(c).trim())
.filter((c) => /^#?[0-9a-fA-F]{6}$/.test(c))
.map((c) => (c.startsWith("#") ? c : `#${c}`));
brandFonts = (t.fonts ?? [])
.map((f) => (typeof f === "string" ? f : (f?.family ?? f?.name ?? "")))
.map((f) => String(f).split(",")[0].replace(/['"]/g, "").trim())
.filter(Boolean);
brandColorStats = Array.isArray(t.colorStats) ? t.colorStats : [];
} catch (e) {
die(`tokens.json parse: ${e.message}`);
}
}
let md = readFileSync(presetFrame, "utf8");
const presetColors = parseColors(md);
const summary = [];
// ── color remix ───────────────────────────────────────────────────────────────
if (brandColors.length && presetColors.length) {
const pr = semanticColors(presetColors);
// Brand roles: prefer the function-based reading of capture colorStats (canvas =
// largest background, accent = top interactive bg, ink = dominant contrasting text).
// Fall back to the legacy luminance/chroma heuristic only when stats are absent —
// but pick the accent via pickAccent either way so a UA-default link color never wins.
const br =
brandRolesFromStats(brandColorStats) ??
(() => {
// strip UA-default link colors so a stray <a> color can't become ink/canvas/accent
const clean = brandColors.filter((h) => !UA_DEFAULT_COLORS.has(h.toUpperCase()));
const s = semanticColors(clean.map((h, i) => [`c${i}`, h]));
return {
ink: s.ink,
canvas: s.canvas,
accent: pickAccent(brandColorStats, clean, [s.ink, s.canvas]) ?? s.accent,
accent2: s.accent2,
};
})();
if (!br.accent) die("accent 选取失败:品牌色里没有可用的强调色");
if (chroma(br.accent) <= 40) {
console.warn(
` ⚠ accent ${br.accent} 彩度很低 (${chroma(br.accent)}) — 确认这是品牌色而非中性/默认色`,
);
}
// Map by LUMINANCE POLARITY, not by role name: the preset's darker neutral takes the
// brand's darker neutral, the lighter takes the lighter. So a dark-ground preset stays
// dark and a light-ground preset stays light — both land on the brand's real values,
// even when the brand's canvas is dark (dark-mode brand) and ink is light.
const darker = (a, b) => ((lum(a) ?? 0) <= (lum(b) ?? 0) ? a : b);
const prDark = darker(pr.ink, pr.canvas);
const prLight = prDark === pr.ink ? pr.canvas : pr.ink;
const brDark = darker(br.ink, br.canvas);
const brLight = brDark === br.ink ? br.canvas : br.ink;
const prAccentHsl = hexToHsl(pr.accent);
const prAccent2Hsl = hexToHsl(pr.accent2);
const newByKey = new Map();
for (const [key, val] of presetColors) {
const ph = hexToHsl(val);
let next;
if (val === prDark) next = brDark;
else if (val === prLight) next = brLight;
else if (val === pr.accent)
next = br.accent; // primary accent → the EXACT brand color
else if (pr.accent2 !== pr.accent && val === pr.accent2)
next = br.accent2; // exact 2nd accent
else if (!ph)
next = val; // non-hex (rgba) → leave as-is
else {
// repaint the remaining tints: pick the brand accent whose preset counterpart is
// nearest in hue, then keep THIS color's own lightness so tint families stay families.
const useSecond =
pr.accent !== pr.accent2 &&
prAccentHsl &&
prAccent2Hsl &&
hueDist(ph.h, prAccent2Hsl.h) < hueDist(ph.h, prAccentHsl.h);
const bh = hexToHsl(useSecond ? br.accent2 : br.accent);
next = bh ? hslToHex(bh.h, bh.s, ph.l) : val;
}
if (next !== val) newByKey.set(key, next);
}
// rewrite only the value of each colors: line; everything else byte-identical.
let inBlock = false;
md = md
.split(/\r?\n/)
.map((line) => {
if (/^colors:\s*$/.test(line)) {
inBlock = true;
return line;
}
if (inBlock && /^\S/.test(line)) inBlock = false;
if (!inBlock) return line;
const m = line.match(/^(\s+)([\w-]+):\s*["']?[^"'\n]*["']?\s*$/);
if (m && newByKey.has(m[2])) return `${m[1]}${m[2]}: "${newByKey.get(m[2])}"`;
return line;
})
.join("\n");
summary.push(
`colors: dark ${prDark}→${brDark}, light ${prLight}→${brLight}, accent ${pr.accent}→${br.accent}` +
` (${newByKey.size}/${presetColors.length} keys repainted${brandColorStats.length ? ", via colorStats" : ""})`,
);
} else {
summary.push(
brandColors.length
? "colors: preset has no parseable colors — kept"
: "colors: no brand colors — preset palette kept",
);
}
// ── font remix ────────────────────────────────────────────────────────────────
if (brandFonts.length) {
const pf = parseFonts(md);
const strip = (q) => (q ? q.replace(/^"|"$/g, "") : null);
const pDisplay = strip(pf.display);
const pBody = strip(pf.body);
const bDisplay = brandFonts[0];
const bBody = brandFonts[1] ?? brandFonts[0];
if (pDisplay && bDisplay) md = md.split(`"${pDisplay}"`).join(`"${bDisplay}"`);
if (pBody && pBody !== pDisplay && bBody) md = md.split(`"${pBody}"`).join(`"${bBody}"`);
summary.push(`fonts: display ${pDisplay}→${bDisplay}, body ${pBody}→${bBody}`);
} else {
summary.push("fonts: no brand fonts — preset fonts kept");
}
// ── write frame.md ────────────────────────────────────────────────────────────
const framePath = join(hyperframesDir, "frame.md");
writeFileSync(framePath, md);
// ── copy caption-skin.html ────────────────────────────────────────────────────
const presetSkin = join(presetDir, presetName, "caption-skin.html");
let skinCopied = false;
if (existsSync(presetSkin)) {
copyFileSync(presetSkin, join(hyperframesDir, "caption-skin.html"));
skinCopied = true;
}
// ── self-validate ─────────────────────────────────────────────────────────────
const outColors = parseColors(md);
if (outColors.length !== presetColors.length) {
die(`color keys changed (${presetColors.length}→${outColors.length}) — keys must be preserved`);
}
const outRoles = semanticColors(outColors);
const li = lum(outRoles.ink),
lc = lum(outRoles.canvas);
if (li != null && lc != null && li >= lc) {
die(
`ink (${outRoles.ink}, lum ${li.toFixed(0)}) is not darker than canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) — bad brand mapping`,
);
}
console.log(`✓ build-frame: ${presetName} → ${framePath}`);
for (const s of summary) console.log(` ${s}`);
console.log(
` caption-skin.html: ${skinCopied ? "copied" : "preset ships none — captions will use the default pill"}`,
);
console.log(` self-check: keys preserved, ink darker than canvas ✓`);
#!/usr/bin/env node
// captions.mjs — build the captions sub-composition from STORYBOARD + audio_meta.
//
// One mode: `build`. Reads STORYBOARD.md (frame order + durations → cumulative
// frame starts) + audio_meta.json (voices[].words, frame-relative) → absolute-
// timed caption groups → writes:
// compositions/captions.html — a self-contained sub-composition the index
// assembler mounts on its captions track (data-composition-id="captions").
// caption_groups.json — the computed groups (debug / inspection / --out).
// caption-overrides.json — an empty `[]` shim (silences the captions runtime's
// validate-time fetch; only written when captions.html is).
// No narration / no words → legal skip: nothing written, assemble-index then omits
// the captions track (it keys off compositions/captions.html existence).
//
// node captions.mjs build --storyboard ./STORYBOARD.md --audio-meta ./audio_meta.json --hyperframes . --out ./caption_groups.json
//
// CAPTION LOOK — two sources, picked automatically:
// 1. PRESET SKIN (preferred). If a project-local `caption-skin.html` exists (Step 2
// copies the chosen frame-preset's skin into the project), it is the caption look.
// It is a brand-token-strict skin with three reserved holes; this script fills them
// and wraps the result in a <template> for the engine:
// - `var GROUPS = [];` → the computed caption groups
// - `var DURATION = 0;` + data-duration="0" (and data-width/height="0") → real values
// - `<style data-brand-tokens></style>` → :root tokens derived from the project's
// frame.md (colors + fonts), mapped to a fixed semantic vocab every skin shares:
// --cap-ink / --cap-canvas / --cap-accent / --cap-accent-2 / --font-display /
// --font-body, plus --cap-band-top / --cap-band-height (the keep-out band).
// So the brand-token overlay from Step 2 flows into the captions automatically.
// 2. DEFAULT (fallback). No skin file → the built-in Roboto/black pill (buildCaptionsHtml).
//
// Grouping mirrors the proven heuristics (frame boundary · sentence-end punct ·
// silence gap · density-aware word cap); word timings come inline from audio_meta.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { captionBand, parseFormat } from "./lib/dimensions.mjs";
import { parseColors, parseFonts, semanticColors } from "./lib/tokens.mjs";
const flag = (argv, name, def) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
};
const r3 = (x) => Number(x.toFixed(3));
// ── grouping params ───────────────────────────────────────────────────────────
const SILENCE_GAP = 0.18; // s of silence between words → split
const TAIL_PAD = 0.12; // s the group lingers after its last word
const SENT_END = /[.?!,;:—]$/;
const DENSITY_WINDOW = 1.0; // s window for words/sec density
function wordCap(density) {
return density > 3.5 ? 2 : density > 2.5 ? 3 : 4;
}
function runBuild(argv) {
const skip = (reason) => {
console.log(`captions: skipped (${reason})`);
process.exit(0);
};
const die = (m) => {
console.error(`✗ captions build: ${m}`);
process.exit(1);
};
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
const audioMetaPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json")));
const outPath = resolve(flag(argv, "out", join(hyperframesDir, "caption_groups.json")));
const htmlPath = join(hyperframesDir, "compositions/captions.html");
const overridesPath = join(hyperframesDir, "caption-overrides.json");
const skinPath = resolve(flag(argv, "skin", join(hyperframesDir, "caption-skin.html")));
const framePath = resolve(flag(argv, "frame", join(hyperframesDir, "frame.md")));
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
const { width: W, height: H } = parseFormat(manifest.globals.format);
if (!existsSync(audioMetaPath)) skip("no audio_meta.json (silent film)");
const meta = JSON.parse(readFileSync(audioMetaPath, "utf8"));
if (!Array.isArray(meta.voices) || meta.voices.length === 0) skip("no narration");
// cumulative frame starts (by frame number) + total duration, from STORYBOARD.
const startByFrame = new Map();
let acc = 0;
for (const f of manifest.frames) {
if (f.number != null) startByFrame.set(f.number, acc);
acc += Number.isFinite(f.durationSeconds) ? f.durationSeconds : 0;
}
const total = r3(acc);
// absolute word stream: frame start + frame-relative word timing.
const words = [];
for (const v of meta.voices) {
const base = startByFrame.get(v.frame);
if (base == null || !Array.isArray(v.words)) continue;
for (const w of v.words) {
const text = String(w.text ?? "").trim();
if (!text || /^[.?!,;:—–-]+$/.test(text)) continue; // drop empties + bare punctuation
if (!isFinite(w.start) || !isFinite(w.end)) continue;
words.push({ text, start: r3(base + w.start), end: r3(base + w.end), frame: v.frame });
}
}
words.sort((a, b) => a.start - b.start);
if (words.length === 0) skip("no usable words");
// density at i = words whose start falls within [w.start, w.start + WINDOW).
const densityAt = (i) => {
const t0 = words[i].start;
let n = 0;
for (let j = i; j < words.length && words[j].start < t0 + DENSITY_WINDOW; j++) n++;
return n / DENSITY_WINDOW;
};
// group: split on frame change / silence gap / word cap; always flush after a
// sentence-ending word.
const groups = [];
let cur = null;
for (let i = 0; i < words.length; i++) {
const w = words[i];
const prev = cur && cur.words[cur.words.length - 1];
const crossFrame = cur && w.frame !== cur.frame;
const gap = prev && w.start - prev.end > SILENCE_GAP;
const full = cur && cur.words.length >= cur.cap;
if (!cur || crossFrame || gap || full) {
if (cur) groups.push(cur);
cur = { frame: w.frame, cap: wordCap(densityAt(i)), words: [] };
}
cur.words.push(w);
if (SENT_END.test(w.text)) {
groups.push(cur);
cur = null;
}
}
if (cur) groups.push(cur);
// finalize: ids, start/end (tail-padded, clamped < next group's start), text.
const finalized = groups.map((g, gi) => {
const first = g.words[0];
const last = g.words[g.words.length - 1];
const next = groups[gi + 1];
let end = r3(last.end + TAIL_PAD);
if (next && next.words[0].start < end) end = r3(next.words[0].start);
return {
id: `caption-group-${gi}`,
frame: g.frame,
start: r3(first.start),
end,
text: g.words.map((w) => w.text).join(" "),
words: g.words.map((w, wi) => ({
id: `caption-word-${gi}-${wi}`,
text: w.text,
start: r3(w.start),
end: r3(w.end),
})),
};
});
// ── write caption_groups.json ──
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(
outPath,
JSON.stringify({ total_duration_s: total, width: W, height: H, groups: finalized }, null, 2),
);
// ── write compositions/captions.html (preset skin if present, else default) ──
mkdirSync(dirname(htmlPath), { recursive: true });
let source;
if (existsSync(skinPath)) {
const tokens = frameTokensCss(framePath, H);
const faces = brandFontFaces(framePath, hyperframesDir);
const fonts = existsSync(framePath) ? parseFonts(readFileSync(framePath, "utf8")) : {};
writeFileSync(
htmlPath,
buildFromSkin(
readFileSync(skinPath, "utf8"),
finalized,
total,
W,
H,
tokens,
die,
faces,
fonts,
),
);
source = `preset skin (${skinPath.replace(hyperframesDir + "/", "")})`;
} else {
writeFileSync(htmlPath, buildCaptionsHtml(finalized, total, W, H));
source = "default (built-in pill)";
}
// ── write caption-overrides.json shim ──
// Atomic create-if-absent: `wx` throws if the file already exists (which we
// ignore) — no existsSync→writeFileSync TOCTOU gap.
try {
writeFileSync(overridesPath, "[]\n", { flag: "wx" });
} catch {
/* overrides shim already present */
}
console.log(
`✓ captions build: ${finalized.length} group(s) from ${words.length} words → compositions/captions.html (total ${total}s) · skin: ${source}`,
);
}
// ── preset-skin path ────────────────────────────────────────────────────────
// Fill the skin's three reserved holes + the root's 0-placeholders, then wrap the
// fragment in a <template> (the engine clones template contents only). One generic
// fill works for every preset's skin — no per-skin transform.
//
// Every preset's skin is authored against ITS OWN fonts/metrics (broadside→Barlow @
// line-height 1.02, capsule→Bodoni, …). When the project's brand font differs (it
// almost always does), three things must be reconciled so ANY skin renders correctly
// for ANY brand — done here generically, not per-project:
// · @font-face for the brand fonts (else the renderer can't supply them → fallback)
// · the skin's preset-font FALLBACK literals (var(--font-x, "Barlow")) repointed to
// the brand family, so no undeclared font name trips font_family_without_font_face
// · a metric safety net: a heavier brand font overflows a tight preset line-height,
// so the active-word highlight clips — a line-height floor + word padding fixes it
// · data-composition-id + dimensions on the <template> root (skins lead with
// <script>/<style>, so the root element must carry the id, not the first child)
function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts = {}) {
const fillOnce = (src, re, repl, label) => {
const n = (src.match(re) || []).length;
if (n !== 1) die(`caption-skin.html: expected exactly one ${label}, found ${n}`);
return src.replace(re, () => repl);
};
let out = skin;
// Strip HTML doc-comments first. A skin's authoring comment can contain tag-like text
// (broadside's literally says "<template>"), which the linter's tag scanner then picks
// up as the root element → false root_missing_composition_id / root_missing_dimensions.
// The comments are preview/authoring docs, not needed in the generated composition.
// Strip in a fixpoint loop, not a single global pass: removing one comment can
// re-form a marker from a nested/partial pair (e.g. <!--<!---->-->), which one
// pass misses — CodeQL flags the single replace as incomplete sanitization.
for (let prev = ""; prev !== out; ) {
prev = out;
out = out.replace(/<!--[\s\S]*?-->/g, "");
}
// brand :root tokens + @font-face for the brand fonts, both into the reserved hole
out = fillOnce(
out,
/<style data-brand-tokens>\s*<\/style>/,
`<style data-brand-tokens>\n${faces ? faces + "\n" : ""}${tokens}\n </style>`,
"<style data-brand-tokens></style> hole",
);
// Resolve the skin's font-family var()s to the brand family LITERAL. Two reasons:
// (1) the linter's used-font scanner naively comma-splits, so var(--x, "Brand") yields
// junk tokens ('var(--x', 'brand")') that never match the @font-face → a false
// font_family_without_font_face; a plain "Brand" literal matches the @font-face.
// (2) it drops the preset's own fallback name (Barlow / IBM Plex Mono / …), which has
// no @font-face in this project. The :root token stays for any other consumer.
if (fonts.display)
out = out.replace(/var\(\s*--font-display\s*(?:,\s*"[^"]*"\s*)?\)/g, fonts.display);
if (fonts.body) out = out.replace(/var\(\s*--font-body\s*(?:,\s*"[^"]*"\s*)?\)/g, fonts.body);
out = fillOnce(
out,
/var GROUPS = \[\];/,
`var GROUPS = ${JSON.stringify(groups)};`,
"`var GROUPS = [];` hole",
);
out = fillOnce(out, /var DURATION = 0;/, `var DURATION = ${total};`, "`var DURATION = 0;` hole");
out = fillOnce(out, /data-duration="0"/, `data-duration="${total}"`, '`data-duration="0"` hole');
out = fillOnce(out, /data-width="0"/, `data-width="${W}"`, '`data-width="0"` hole');
out = fillOnce(out, /data-height="0"/, `data-height="${H}"`, '`data-height="0"` hole');
// font-robust safety net — appended last so it wins the cascade over the skin's own
// (preset-font-tuned) line-height. Kept SNUG (1.1) so the plate hugs the text. NO extra
// word/pill padding: inspect's `text_box_overflow` on the highlight words is a cosmetic
// false-positive here (heavy-glyph ink slightly exceeds the line box, but there's no
// overflow:hidden — nothing is clipped); zeroing it would need an airy line-height that
// balloons the pill, which is worse. Override only if a brand font genuinely clips.
out += "\n<style>\n .caption-line { line-height: 1.1 !important; }\n</style>";
return `<template id="captions-template" data-composition-id="captions" data-width="${W}" data-height="${H}">\n${out.trim()}\n</template>\n`;
}
// @font-face for the brand display/body fonts, matched from the project's font dirs
// (staged assets/fonts first, else capture/assets/fonts) by family-name prefix, with
// weight parsed from the filename. Paths are relative to compositions/captions.html.
// Returns "" when frame.md or font files are absent (then the skin's fallback applies).
function brandFontFaces(framePath, hyperframesDir) {
if (!existsSync(framePath)) return "";
const { display, body } = parseFonts(readFileSync(framePath, "utf8"));
const families = [
...new Set([display, body].filter(Boolean).map((f) => f.replace(/^"|"$/g, ""))),
];
if (!families.length) return "";
const dirs = [
{ abs: join(hyperframesDir, "assets/fonts"), rel: "../assets/fonts" },
{ abs: join(hyperframesDir, "capture/assets/fonts"), rel: "../capture/assets/fonts" },
].filter((d) => existsSync(d.abs));
const weightOf = (n) => {
const s = n.toLowerCase();
if (/black|heavy|ultra|extrabold/.test(s)) return 800;
if (/bold/.test(s)) return 700;
if (/semibold|demibold/.test(s)) return 600;
if (/medium/.test(s)) return 500;
if (/light|thin/.test(s)) return 300;
return 400; // book / regular / roman
};
const fmtOf = (f) =>
/\.woff2$/i.test(f)
? "woff2"
: /\.woff$/i.test(f)
? "woff"
: /\.ttf$/i.test(f)
? "truetype"
: "opentype";
const faces = [];
const seen = new Set();
for (const fam of families) {
const key = fam.replace(/\s+/g, "").toLowerCase();
for (const d of dirs) {
let files = [];
try {
files = readdirSync(d.abs);
} catch {
continue;
}
for (const f of files.sort()) {
if (!/\.(woff2|woff|ttf|otf)$/i.test(f)) continue;
if (!f.replace(/\s+/g, "").toLowerCase().startsWith(key)) continue;
const w = weightOf(f);
const dedup = `${fam}-${w}`;
if (seen.has(dedup)) continue; // one src per weight; assets/fonts wins over capture
seen.add(dedup);
faces.push(
` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${fmtOf(f)}'); font-weight: ${w}; font-display: block; }`,
);
}
}
}
return faces.join("\n");
}
// frame.md colors:/typography: → a :root token block, mapped to the fixed semantic
// vocab every preset skin references. Robust to per-preset key names: colors are
// matched by name, then by luminance. Brand-token overlay (Step 2) flows through
// because the values come from the project's frame.md. No frame.md → band vars only.
function frameTokensCss(framePath, H) {
const band = captionBand(H);
const out = [];
if (existsSync(framePath)) {
const md = readFileSync(framePath, "utf8");
const colors = parseColors(md);
for (const [k, v] of colors) out.push(` --${k}: ${v};`); // raw, for completeness
const sem = semanticColors(colors);
if (sem.ink) out.push(` --cap-ink: ${sem.ink};`);
if (sem.canvas) out.push(` --cap-canvas: ${sem.canvas};`);
if (sem.accent) out.push(` --cap-accent: ${sem.accent};`);
if (sem.accent2) out.push(` --cap-accent-2: ${sem.accent2};`);
const { display, body } = parseFonts(md);
if (display) out.push(` --font-display: ${display}, system-ui, serif;`);
if (body) out.push(` --font-body: ${body}, system-ui, sans-serif;`);
}
out.push(` --cap-band-top: ${band.bandTopY}px;`);
out.push(` --cap-band-height: ${band.bandHeight}px;`);
return ` :root {\n${out.join("\n")}\n }`;
}
// ── default path (no preset skin) ─────────────────────────────────────────────
// Self-contained captions sub-composition. The <template> holds the band container
// + style AND the <script> (the HyperFrames loader only executes scripts INSIDE the
// cloned template — a sibling <script> after </template> never runs, so the timeline
// never registers and captions render blank). The script builds per-word spans and a
// paused, seek-safe GSAP timeline (opacity for group show/hide, a quick color tween
// per word for the karaoke highlight — no className flips, no JS state) and ends each
// group with a hard tl.set kill so an exit can't get stuck. gsap is loaded via CDN
// inside the template (matching the frame compositions). Band = captionBand(H).
function buildCaptionsHtml(groups, total, W, H) {
const band = captionBand(H);
const fs = Math.round(H * 0.038);
const pad = Math.round(fs * 0.4);
return `<template id="captions-template">
<div
data-composition-id="captions"
data-width="${W}"
data-height="${H}"
data-duration="${total}"
id="captions-root"
>
<div id="cap"></div>
</div>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
#cap {
position: absolute;
left: 0;
right: 0;
top: ${band.bandTopY}px;
height: ${band.bandHeight}px;
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
position: absolute;
max-width: 80%;
padding: ${pad}px ${Math.round(pad * 1.8)}px;
background: rgba(0, 0, 0, 0.72);
border-radius: ${Math.round(fs * 0.3)}px;
font-family: Roboto, sans-serif;
font-weight: 700;
font-size: ${fs}px;
line-height: 1.25;
text-align: center;
color: #fff;
opacity: 0;
}
.caption-word {
color: rgba(255, 255, 255, 0.55);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
var GROUPS = ${JSON.stringify(groups)};
var cap = document.getElementById("cap");
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (g) {
var el = document.createElement("div");
el.className = "caption-group";
g.words.forEach(function (w) {
var s = document.createElement("span");
s.className = "caption-word";
s.textContent = w.text + " ";
el.appendChild(s);
});
cap.appendChild(el);
tl.fromTo(el, { opacity: 0 }, { opacity: 1, duration: 0.18, overwrite: "auto" }, g.start);
tl.to(el, { opacity: 0, duration: 0.12, overwrite: "auto" }, g.end);
tl.set(el, { opacity: 0, visibility: "hidden" }, g.end + 0.12); // deterministic hard kill
g.words.forEach(function (w, i) {
tl.to(el.children[i], { color: "#ffffff", duration: 0.06 }, w.start);
});
});
tl.to({}, { duration: ${total} }, 0); // full-span anchor
window.__timelines = window.__timelines || {};
window.__timelines["captions"] = tl;
})();
</script>
</template>
`;
}
const sub = process.argv[2];
if (sub === "build" || sub === undefined) runBuild(process.argv.slice(sub === "build" ? 3 : 2));
else {
console.error(
"usage: node captions.mjs build [--storyboard …] [--audio-meta …] [--hyperframes .]",
);
process.exit(2);
}
// assets.mjs — stage frame-named capture assets into assets/.
// Shared by stage-assets.mjs (Step 4 close, BEFORE the frame workers run) and
// assemble-index.mjs (Step 5, idempotent backstop). Only assets a frame names
// in `asset_candidates` are staged; unnamed assets never reach the project.
// asset_candidates value form: "assets/<basename> — desc; assets/… — …".
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join } from "node:path";
export function basenamesFromCandidates(value) {
if (typeof value !== "string") return [];
return value
.split(";")
.map((seg) => seg.split(/\s+[—–-]\s+/)[0].trim()) // strip the " — description"
.filter(Boolean)
.map((p) => basename(p.replace(/^assets\//, "")));
}
// Copy each frame's asset_candidates from capture/{assets,assets/videos,
// screenshots} into assets/. Already-staged files are left as is (first-wins),
// so calling this twice is safe. Returns { staged, wanted, anomalies }.
export function stageAssets({ hyperframesDir, frames }) {
const wanted = new Set();
for (const f of frames) {
for (const b of basenamesFromCandidates(f.extra?.asset_candidates)) wanted.add(b);
}
const captureDirs = [
join(hyperframesDir, "capture/assets"),
join(hyperframesDir, "capture/assets/videos"), // videos download into a subdir
join(hyperframesDir, "capture/screenshots"),
];
const assetsDir = join(hyperframesDir, "assets");
const anomalies = [];
let staged = 0;
if (wanted.size > 0) {
mkdirSync(assetsDir, { recursive: true });
for (const b of wanted) {
const dest = join(assetsDir, b);
if (existsSync(dest)) {
staged++;
continue;
} // first-wins / already staged
const src = captureDirs.map((d) => join(d, b)).find((p) => existsSync(p));
if (src) {
copyFileSync(src, dest);
staged++;
} else {
anomalies.push(
`asset "${b}" named by a frame but not found under capture/ — frame will 404 it`,
);
}
}
}
return { staged, wanted, anomalies };
}
// dimensions.mjs — canvas size + caption-band geometry for the product-launch
// pipeline. Single source of truth = the STORYBOARD frontmatter `format` global
// ("1920x1080" / "1080x1920" / "1080x1080", or a named orientation). Every
// script and the index assembler reads the size from here; none hardcodes it.
// Named orientation presets. Square/portrait are 1080-based so they share the
// long-edge pixel budget with landscape (same render-cost ballpark).
export const ORIENTATION_PRESETS = {
landscape: { width: 1920, height: 1080 }, // 16:9 — default
portrait: { width: 1080, height: 1920 }, // 9:16 — reels / shorts / TikTok
square: { width: 1080, height: 1080 }, // 1:1 — feed
};
export const DEFAULT_DIMENSIONS = ORIENTATION_PRESETS.landscape;
function sane(w, h) {
return Number.isFinite(w) && Number.isFinite(h) && w >= 240 && h >= 240 && w <= 8192 && h <= 8192;
}
// Parse a STORYBOARD `format` global into { width, height, source }. Accepts
// "WxH" (e.g. "1920x1080"; `x` or `×`, any inner spacing) or a named orientation;
// falls back to landscape so a storyboard with a missing/garbled format still
// renders (no behavior change vs the old landscape lock).
export function parseFormat(format) {
const s = typeof format === "string" ? format.trim().toLowerCase() : "";
if (ORIENTATION_PRESETS[s]) return { ...ORIENTATION_PRESETS[s], source: `orientation=${s}` };
const m = s.match(/^(\d+)\s*[x×]\s*(\d+)$/);
if (m) {
const w = parseInt(m[1] ?? "", 10);
const h = parseInt(m[2] ?? "", 10);
if (sane(w, h)) return { width: w, height: h, source: "format" };
}
return { ...DEFAULT_DIMENSIONS, source: "default(landscape)" };
}
// Caption band geometry, derived from canvas height: the bottom ~16.67% (180px
// at h=1080). Frame content must end `safetyPx` above the band top. Holds even
// when captions are disabled (bottom-edge consistency).
export const CAPTION_BAND_FRACTION = 0.1667;
export function captionBand(height, safetyPx = 20) {
const h = Number.isFinite(height) ? height : DEFAULT_DIMENSIONS.height;
const bandHeight = Math.round(h * CAPTION_BAND_FRACTION);
const bandTopY = h - bandHeight; // foreground must end at/above this y
return { bandHeight, bandTopY, foregroundMaxY: bandTopY - safetyPx };
}
// transition-registry.mjs — loader for this skill's vendored transition registry
// (./transitions.json). The registry is the curated Tier-B subset (transform /
// opacity / filter on the two frame clip wrappers `#el-<id>`, no overlay DOM) +
// each type's GSAP template. Vendored into the skill so it ships standalone; the
// recipes originate from the shared catalog skills/hyperframes-animation/
// transitions/ (css-*.md) — keep them in step if those shared recipes change.
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
export const DEFAULT_REGISTRY_PATH = resolve(here, "./transitions.json");
let _cache = null;
export function loadTransitionRegistry(registryPath = DEFAULT_REGISTRY_PATH) {
if (_cache && _cache.path === registryPath) return _cache.data;
let data;
try {
data = JSON.parse(readFileSync(registryPath, "utf8"));
} catch (e) {
throw new Error(`transition registry not loadable at ${registryPath}: ${e.message}`);
}
if (!Array.isArray(data.transitions) || data.transitions.length === 0) {
throw new Error(`transition registry ${registryPath} has no transitions[]`);
}
_cache = { path: registryPath, data };
return data;
}
// Convenience: a Map name -> transition record.
export function transitionsByName(registryPath = DEFAULT_REGISTRY_PATH) {
const data = loadTransitionRegistry(registryPath);
const map = new Map();
for (const t of data.transitions) map.set(t.name, t);
return map;
}
{
"_comment": "Vendored transition registry for the product-launch workflow — the curated Tier-B subset (transform/opacity/filter on the two frame clip wrappers #el-<id>, no overlay DOM, no per-frame cooperation). Each type carries its GSAP template; the transitions.mjs injector stamps it onto window.__timelines[\"main\"]. Recipes originate from the shared catalog skills/hyperframes-animation/transitions/ (css-*.md) — keep in step if those change. Token placeholders the injector substitutes: __OLD__ (#el-<from>), __NEW__ (#el-<to>), __T__ (overlap-start s), __DUR__ (this boundary's duration), __DX__/__DXIN__ (horizontal travel + incoming offset), __DY__/__DYIN__ (vertical).",
"transitions": [
{
"name": "crossfade",
"energy": "any",
"default_duration_s": 0.5,
"directions": [],
"source": "css-dissolve.md",
"gsap_template": [
"tl.to(__OLD__, { opacity: 0, duration: __DUR__, ease: \"power2.inOut\" }, __T__);",
"tl.fromTo(__NEW__, { opacity: 0 }, { opacity: 1, duration: __DUR__, ease: \"power2.inOut\" }, __T__);"
]
},
{
"name": "blur-crossfade",
"energy": "calm",
"default_duration_s": 0.6,
"directions": [],
"source": "css-dissolve.md",
"note": "Default when the two frames' #root backgrounds differ a lot — the blur masks the background-color clash a plain crossfade would expose.",
"gsap_template": [
"tl.to(__OLD__, { filter: \"blur(10px)\", scale: 1.03, opacity: 0, duration: __DUR__, ease: \"power2.inOut\" }, __T__);",
"tl.fromTo(__NEW__, { filter: \"blur(10px)\", scale: 0.97, opacity: 0 }, { filter: \"blur(0px)\", scale: 1, opacity: 1, duration: __DUR__, ease: \"power2.inOut\" }, __T__);"
]
},
{
"name": "push-slide",
"energy": "medium",
"default_duration_s": 0.5,
"directions": ["LEFT", "RIGHT", "UP", "DOWN"],
"default_direction": "LEFT",
"source": "css-push.md",
"note": "Directional. The injector picks __DX__/__DY__ from the direction and emits the horizontal OR vertical pair (not both).",
"gsap_template_horizontal": [
"tl.to(__OLD__, { x: __DX__, duration: __DUR__, ease: \"power3.inOut\" }, __T__);",
"tl.fromTo(__NEW__, { x: __DXIN__, opacity: 1 }, { x: 0, duration: __DUR__, ease: \"power3.inOut\" }, __T__);"
],
"gsap_template_vertical": [
"tl.to(__OLD__, { y: __DY__, duration: __DUR__, ease: \"power3.inOut\" }, __T__);",
"tl.fromTo(__NEW__, { y: __DYIN__, opacity: 1 }, { y: 0, duration: __DUR__, ease: \"power3.inOut\" }, __T__);"
]
},
{
"name": "zoom-through",
"energy": "high",
"default_duration_s": 0.4,
"directions": [],
"source": "css-scale.md",
"gsap_template": [
"tl.to(__OLD__, { scale: 2.5, opacity: 0, filter: \"blur(8px)\", duration: __DUR__, ease: \"power3.in\" }, __T__);",
"tl.fromTo(__NEW__, { scale: 0.5, opacity: 0, filter: \"blur(8px)\" }, { scale: 1, opacity: 1, filter: \"blur(0px)\", duration: __DUR__, ease: \"power3.out\" }, __T__);"
]
},
{
"name": "squeeze",
"energy": "medium",
"default_duration_s": 0.4,
"directions": [],
"source": "css-push.md",
"note": "Old compresses to a vertical line on the left edge; new expands from the right edge. Incoming starts off (scaleX 0) so its higher-track stacking is harmless.",
"gsap_template": [
"tl.to(__OLD__, { scaleX: 0, transformOrigin: \"left center\", duration: __DUR__, ease: \"power3.inOut\" }, __T__);",
"tl.fromTo(__NEW__, { scaleX: 0, transformOrigin: \"right center\", opacity: 1 }, { scaleX: 1, transformOrigin: \"right center\", duration: __DUR__, ease: \"power3.inOut\" }, __T__);"
]
}
],
"default_high_energy": "zoom-through",
"default_calm": "blur-crossfade",
"max_duration_s": 2.0
}
Related skills
How it compares
Use product-launch-video for explicit product marketing launches; use website-to-video when the goal is a neutral portfolio or landing-page tour.
FAQ
What inputs does product-launch-video accept?
product-launch-video accepts a product or marketing URL, a pasted script, or a written brief, then captures brand context and builds a frame-by-frame HyperFrames launch video.
How long are product-launch-video outputs?
product-launch-video targets polished launch and promo clips around 60 seconds, suited to SaaS announcements, feature reveals, and app launch marketing.
Is Product Launch Video safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.