
Motion Graphics Skill
- 485k installs
- 44.2k repo stars
- Updated September 6, 2026
motion-graphics is a HyperFrames skill for creating short unnarrated motion graphics like kinetic type, stats animations, and social content.
npx skills add https://github.com/heygen-com/hyperframes --skill motion-graphicsInstall if
Creating social media motion graphics and animated design elements for marketing.
Skip if
Long brand reels, narrated explainers, website-to-video walkthroughs, or product launch promos requiring general-video.
What motion-graphics covers
- Create short unnarrated motion graphics under 10 seconds with kinetic type, stats, and logo elements
- Output MP4 or transparent overlays for platform-specific use
- Design-led motion creation with HyperFrames animation workflows
motion-graphics by the numbers
| All-time installs | 485,251 (485k) | skills.sh registry |
|---|---|---|
| Repo stars | ★ 44.2k | GitHub |
| Security audit | 2 / 3 scanners passed | skills.sh audit |
| Repo last updated | September 6, 2026 | GitHub |
| Repository | heygen-com/hyperframes ↗ | |
Data as of Oct 19, 2026 (Skillselion catalog sync)
What can the motion-graphics skill do in Claude Code?
- Capabilities
- motion creation · overlay generation · animation authoring
- Use cases
- video generation · marketing
What motion-graphics says it does
MP4 or transparent overlay
What does the Motion Graphics skill produce?
Short HyperFrames compositions rendered to MP4 or transparent overlay for typography, logos, and data hits.
- Short MP4 renders
- Transparent overlay compositions
- Kinetic typography scenes
- Targets clips usually under 10 seconds and up to roughly 30 seconds
What files ship with motion-graphics?
motion-graphics — dispatch entry
Confirm the route before Step 0. This skill makes a short, design-led, unnarrated motion graphic (motion is the message; ~under 10s, no voice-over). A longer, multi-scene, or narrated treatment →
/general-video; a narrated video of a website →/website-to-video; a topic explainer →/faceless-explainer; a product promo →/product-launch-video; captions on existing footage →/embedded-captions. Out of scope: live / at-render-time data, or footage it can't capture. Unsure motion-first-vs-narrated? Read/hyperframesfirst.
A short design-led motion graphic. Asset-first: decide the asset strategy and source real material _before_ designing the shot, then design the shot around what you have, then compose by reusing catalog capabilities. All artifacts go to PROJECT_DIR = videos/<project-name>/ (created in Step 0); all paths below are relative to it.
| Phase | Execution | Primary artifact | Detailed flow |
|---|---|---|---|
| init | Bash | hyperframes.json | Step 0 |
| plan | subagent — decide search? + classify + asset strategy | shot-plan.json (draft: category, asset_needs queries, brief) | agents/director.md (Part 1) |
| source ◇ | Bash — media-use resolve (skip if asset_needs is empty) | assets/ + assets/index.md | phases/source/guide.md |
| design | subagent — shot design around resolved assets | shot-plan.json (final: block(s) + layout + motion + positions) | agents/director.md (Part 2) |
| build | subagent — reuse-first composition | compositions/index.html | agents/builder.md |
| render | Bash — hyperframes render (MP4, or --format webm/mov for overlay) | renders/video.mp4 | Step 5 |
| verify | Bash — lint / inspect -> repair subagent on failure | (fixes in place) | agents/finalize.md |
◇ source runs only when the chosen category declares assets. Pure code/text categories (e.g. kinetic-type, most charts/stat) have asset_needs: [] and skip straight from plan to design.
Categories — split by the search decision
plan's first decision is: does this need a search? That fork splits the categories into two groups; then the specific category is picked — for search-driven, by the type of content the search returns. Each category is one categories/<id>/module.md (its planning + build rules); the shared motion vocabulary lives in references/motion-vocabulary.md (→ hyperframes-animation rules/blueprints + registry blocks).
Form categories — no search; the user supplies the content:
| Category | Intent | Leans on |
|---|---|---|
kinetic-type | punchy line / quote / title, motion-first text | caption-* blocks + animation rules |
stat | single hero number / count-up + ring | apple-money-count / rules/{counting-dynamic-scale, stat-bars-and-fills} |
charts | bar / line / pie / race / % from data | data-chart block |
logo-reveal | logo sting / brand lockup (user logo) | logo-outro / rules/svg-path-draw |
lower-thirds | name / title bars, callouts, social overlays | caption-* + registry overlay blocks |
Search-driven categories — search first, then animate by content type (the RWA path):
| Returned content | Category | Animation |
|---|---|---|
| webpage / link | webpage | webpage / UI animation (scroll, reveal, cursor, callouts) |
| news article | news | headline reveal + source card + key-fact callouts |
| tweet | tweet | animated tweet card |
| image / entity | asset-fusion | the asset's geometry _becomes_ the chart (RWA diegetic fusion) |
Build order: one at a time, coverage-first (rough is fine). kinetic-type ported from the prototype; the rest follow.
Prerequisites
macOS Apple Silicon or Linux x64. System tools: brew install node ffmpeg. npx hyperframes doctor once. macOS GPU render: export PRODUCER_BROWSER_GPU_MODE=hardware.
Optional keys (local fallbacks if unset) — only needed by categories that source/generate assets via media-use:
| Key | Used for | Fallback |
|---|---|---|
GEMINI_API_KEY / GOOGLE_API_KEY | image generation (media-use resolve) | skip generate / search-only |
| (asset_scout / search providers) | webpage/news/tweet + asset-fusion real-asset search | category degrades to asset-free |
Flow
Step 0 — Initialize
cwd is the agent workspace root; write all artifacts under PROJECT_DIR = videos/<project-name>/. <project-name>: use the dir the user gave, else a short kebab-case name from the intent (<subject>-motion). Not the workspace basename or a timestamp.
Only when $PROJECT_DIR/hyperframes.json is absent:
PROJECT_DIR="${MOTION_GRAPHICS_DIR:-videos/<project-name>}"
mkdir -p "$(dirname "$PROJECT_DIR")"
npx hyperframes init "$PROJECT_DIR" --non-interactive --skip-skills --example=blankConstraints: never hyperframes init in the workspace root; never nest another hyperframes/ inside PROJECT_DIR; every Bash command (master + subagents) is a (cd "$PROJECT_DIR" && ...) subshell — never bare cd.
Step 1 — Plan (subagent: Director Part 1)
Dispatch one subagent. prompt = full agents/director.md + ## Dispatch context (SKILL_DIR / PROJECT_DIR / the user's request / Schema: <SKILL_DIR>/references/shot-plan-ir.md). It must:
- Decide: does this need a search? (the first fork)
- No → pick a form category (kinetic-type / stat / charts / logo-reveal / lower-thirds); content is user-supplied;
asset_needs: []. - Yes → emit a search plan into
asset_needs[](news / web / tweet / image; two-pole queries). The specific search-driven category (webpage / news / tweet / asset-fusion) is confirmed by the content type returned in Step 2, and finalized in Step 3.
- Write a draft
shot-plan.json(envelope + chosen form category _or_ search intent +asset_needs+ a one-paragraph shot brief). Schema:references/shot-plan-ir.md.
Validation: [ -s "$PROJECT_DIR/shot-plan.json" ] && echo ok || echo missing.
Step 2 — Source ◇ (Bash: media-use, conditional)
If shot-plan.json.asset_needs is non-empty, resolve assets (search / generate / fetch → frozen project-local paths + ledger). See phases/source/guide.md (wraps media-use resolve; the search-driven categories use the news/web/tweet/image search). If asset_needs is empty, skip to Step 3.
# illustrative — see phases/source/guide.md
(cd "$PROJECT_DIR" && node <SKILL_DIR>/phases/source/resolve.mjs --plan ./shot-plan.json --out ./assets)Degrade gracefully: if a search/provider is unavailable, the category falls back to asset-free (note it in context.log).
Step 3 — Design (subagent: Director Part 2)
Dispatch a subagent (prompt = agents/director.md Part 2 + dispatch context including the resolved assets/index.md if Step 2 ran + catalog-map.md). It designs the shot around the available assets: pick the catalog block(s) + the hyperframes-animation rules/blueprints, the layout, the motion, beats, and (for asset-fusion) the element_positions + eyedropper palette. Finalizes shot-plan.json (content.block + content.customize + per-category content).
Step 4 — Build (subagent: Builder, reuse-first)
Dispatch a subagent. prompt = full agents/builder.md + dispatch context (shot-plan.json, catalog-map.md, the category's module.md, references/motion-vocabulary.md, references/builder-contract.md). Reuse-first: npx hyperframes add <block> + customize in place; hand-author only gaps + the asset-fusion affordance. Output compositions/index.html honoring the HF contract (paused GSAP timeline on window.__timelines, class="clip" + stable ids, tl.seek(0), deterministic).
Step 5 — Render (Bash)
(cd "$PROJECT_DIR" && npx hyperframes render . -q draft -o ./renders/video.mp4)
# transparent overlay variant: --format webm (or mov)Step 6 — Verify (Bash → repair subagent on failure)
(cd "$PROJECT_DIR" && npx hyperframes lint . && npx hyperframes inspect .)exit 0 → done. On lint/inspect errors, dispatch the repair subagent (agents/finalize.md: snapshot QA + one in-place fix pass + re-render). Never change a fixed duration in repair.
Report + optional preview
Report the final output (renders/video.mp4, or the .webm / .mov overlay variant) + duration. Don't open a preview during the run. Offer one only on request, started after render so it serves the final file:
(cd "$PROJECT_DIR" && npx hyperframes preview) # Studio UI; or `npx hyperframes play` for a shareable linkFlags live in the hyperframes-cli skill (references/preview-render.md).
Resume table
| State | Continue from |
|---|---|
no shot-plan.json | Step 1 (plan) |
shot-plan.json has asset_needs, no assets/ | Step 2 (source) |
shot-plan.json final, no compositions/index.html | Step 3/4 (design+build) |
compositions/index.html exists, no renders/video.mp4 | Step 5 (render) + Step 6 |
renders/video.mp4 exists | Report + stop |
Design notes (maintainers — execution does not read this)
- Asset-first rationale: sourcing is front-loaded and informs shot design (the RWA flow: analyze → search → review → compose). the search-driven categories (
webpage/news/tweet) andasset-fusionboth lean on media-use search (news/web/tweet/image), which is media-use's documented RWA lineage. - Reuse-first: the in-ecosystem analog of LLM-generated templates is "compose catalog blocks +
hyperframes-animationrules". HF's paused GSAP timeline ≙ Remotion'suseCurrentFrame. - Category module contract: one
categories/<id>/module.md(planning + build), sharingreferences/motion-vocabulary.md(+ optional eval). Adding a category = drop the folder + register its classifier line inagents/director.md+ its row incatalog-map.md; the phase pipeline is untouched. - Directory shape:
videos/<project-name>/
hyperframes.json context.log
shot-plan.json # the IR (Director output)
assets/ assets/index.md # media-use output (if sourced)
compositions/index.html # Builder output
renders/video.mp4- Registration: in
hyperframesrouter — add the "design-led short motion graphic" intent + Workflow description; carve the motion-graphics triggers out of/general-video; add reverse Do-NOT-use edges. Seemotion-graphics-genre.md§5-7.
Motion-Graphics Builder
Turn shot-plan.json into one renderable HyperFrames composition (compositions/index.html). Everything stays in the HF ecosystem — HTML is the source of truth; a single paused GSAP timeline carries all motion; the engine seeks it. Category-specific build rules live in categories/<id>/module.md; this file is the shared contract.
Reuse-first (the default)
Default = compose existing catalog capabilities, not hand-author:
npx hyperframes add <block>(registry) → customize in place. Most blocks bake content/data into their own script (only a few expose CSS-var params), so reuse = add + edit.hyperframes-animationrules / blueprints / transitions for motion; runtime adapters (GSAP default).
Hand-author only (a) gaps no block/rule covers, (b) the asset-fusion affordance binding. The Director named the block(s) + customizations in shot-plan.json (content.block + content.customize); see catalog-map.md.
The HF contract (non-negotiable)
- Root
#stagecarriesdata-composition-id,data-start="0",data-duration=<s>,data-fps,data-width,data-height. - Exactly ONE
gsap.timeline({ paused:true }); registerwindow.__timelines["<id>"] = tl;; end withtl.seek(0). Nevertl.play()for render-critical motion. No timers / async / event-driven timeline build. Finite repeats only. - Timed clips need
class="clip"+ a stableid. Timeline-driven groups inside one full-duration clip don't each need timing attrs. - Fonts: prefer local
@font-face(.woff2) for deterministic / offline render; CDN Google Fonts do render (compiler caches + injects@font-face) but warn + need network. - Deterministic only — no
Date.now()/Math.random()/ network.
Layout before animation
Build the hero-frame end-state in CSS first (flex + padding; never absolute offsets on content containers; the root must be sized). Then gsap.from() entrances INTO it; exits via transitions or the final scene. Full rules: references/builder-contract.md.
IR → composition
content.block→hyperframes addit (or inline) + applycontent.customize.- per-category
content(text scenes / chart data / fusion positions / news-tweet content) → realize percategories/<id>/module.md. - resolved
asset_needs→ reference frozen project-local paths (never a remote URL or a prompt). palette[-1]/ bg +fontfrom the envelope.export: alpha-overlay→ transparent bg; render--format webm(ormov).
Critical correctness (GSAP / seek)
Opacity-gate delayed elements (set hidden until their entrance). Clamp at tween bounds (no overshoot past a held value). Allowed eases: power1–4, back, bounce, circ, elastic, expo, sine (.in/.out/.inOut). One motif per scene. Run hyperframes inspect for overflow / collisions.
Verify-fix
hyperframes lint → inspect → render -q draft. On failure, fix the offending element + re-run. (Remotion-sourced prior art is graded by the /remotion-to-hyperframes SSIM harness.) Never change a fixed data-duration during repair.
Motion-Graphics Director
Turn a request into a shot-plan.json for a short (~3–30s) design-led motion graphic. You run in two parts around the asset-sourcing step: Part 1 (plan) before sourcing, Part 2 (design) after. You do NOT write composition code — that's the Builder. Schema: references/shot-plan-ir.md.
Part 1 — Plan (before sourcing)
Emit a DRAFT shot-plan.json.
- Decide first: does this need a search? No → a form category (user supplies content). Yes → emit a search plan; the specific search-driven category (
webpage/news/tweet/asset-fusion) is confirmed by what the search returns (Step 2 → finalized in Part 2).
- Classify — form categories by intent below; search-driven categories are picked post-search:
| Category | Pick when… |
|---|---|
kinetic-type | a punchy line / quote / title; text is the hero |
stat | a single hero number / count-up |
charts | bar / line / pie / race / % from data |
logo-reveal | a logo sting / brand lockup (user supplies the logo) |
lower-thirds | name/title bars, callouts, social overlays |
maps | a geographic shot — highlight regions, connect places, zoom to a location. Sub-fork: vector (D3, stylized) vs basemap (baked MapLibre — real satellite/dark/zoom-to-place) |
webpage | highlight / animate a real captured web page or UI _(search-driven)_ |
news | a news article → article-highlight: blur → zoom into keyword _(search-driven)_ |
tweet | a tweet → animated card _(search-driven)_ |
asset-fusion | a real photo/asset's geometry _becomes_ the chart _(search-driven)_ |
If genuinely ambiguous between two, ask exactly one question. Then load categories/<id>/module.md for that category's specifics.
- Asset strategy →
asset_needs[]. Each item:{ role, kind: image|icon|logo|svg|news|web|tweet, query|source, treatment }.
- asset-free (
kinetic-type, moststat/charts) →asset_needs: []. maps→ vector lane:asset_needs: [](D3/TopoJSON, runs live in HF). basemap lane (satellite/dark/zoom-to-place):asset_needs: [{ type: "map-bake", … }](baked in Source — seecategories/maps/module.md).webpage/news/tweet→ search the real source (page / article / tweet) + a supporting image. Two-pole queries only: atomic (1–3 words, composable: portraits, logos, objects) OR specific (5–15 words: a news event, a tweet). Never the middle. A failed specific query is dropped, not broadened.asset-fusion→ search or generate one hero asset.logo-reveal→ user-supplied logo (source).
- Envelope:
duration_s(3–30),fps(30),canvas(default 1080×1920; 16:9 / 1:1 per platform),style,palette(hex list, or"derive-from-asset"),font(from the HF embed list),beats,export(mp4|alpha-overlay).
- Shot brief: one paragraph — what the viewer experiences + the single dominant motion idea.
Part 2 — Design (after sourcing)
Given the draft + resolved assets/index.md (if Step 2 ran) + catalog-map.md, design the shot around the assets:
- Pick the catalog block(s) + the
hyperframes-animationrules / blueprints (catalog-aware — seecatalog-map.md). - Layout (hero-frame), motion (per
references/motion-vocabulary.md), beats, pacing, exits. asset-fusion: read the asset's geometric affordance →element_positions(center / extent / safe-zones / avoid-zones) + eyedropper palette from the asset.- Finalize
shot-plan.json:content.block+content.customize+ the per-categorycontent.
Heuristics (design-led short motion)
- Motion IS the message; no narration arc. Hook lands fast (~first 0.5s). One dominant motif. Pattern-interrupt if the piece runs >~2.5s (change exactly one thing). Effect intensity matches the energy. Legibility: a key element stays readable ≥~0.3s. Beats may be anticipated ~0.1s for perceived sync.
- Reuse-first: name a catalog block; ask for hand-authored motion only for gaps + the
asset-fusionaffordance.
Then hand shot-plan.json to the Builder.
Finalize / repair (subagent)
Snapshot visual QA + one in-place fix pass + render. Dispatched only when Step 6 lint/inspect reports issues, or to do the final render.
Dispatch context
SKILL_DIR / PROJECT_DIR / Render quality: draft|standard|high / snapshot times / lint+inspect tails (if any).
Flow
- Snapshots —
npx hyperframes inspect . --at <beat times>; eyeball for: overflow / off-canvas, text collisions, empty frames, wrong content, motion that doesn't read. - One in-place repair pass —
Editcompositions/index.htmlfor the visible issues. Never change a fixeddata-duration(timing is set upstream; changing it breaks assembly). Re-runlint/inspect. - Render —
(cd "$PROJECT_DIR" && npx hyperframes render . -q <quality> -o ./renders/video.mp4)(add--format webmfor an alpha overlay export). Verify the mp4 exists + duration matches.
STOP / escalate
Only when the shot is fundamentally wrong (whole content off, needs recomposition) — return to Step 3/4 (re-design + re-build), don't force it with edits. Small fixes never escalate.
Director → catalog block map (reuse-first)
The Builder's default is to compose existing HF catalog blocks, not hand-author. npx hyperframes add <block> drops a block's source into compositions/; the Builder then customizes in place — most blocks bake their content/data into their own script (only a few expose CSS-var params), so reuse = add + edit, not pure variable injection. Hand-author only (a) gaps no block covers and (b) the asset-fusion affordance binding.
After classifying intent → category, the Director names the block(s) + what to customize in the shot-plan IR:
"content": { "block": "data-chart", "customize": { "data": [...], "headline": "…", "palette": ["…"] } }Category → block(s)
| Category | Borrow from catalog | Customize | Gaps → hand-author |
|---|---|---|---|
| kinetic-typo | one of *18 `caption-`** (kinetic-slam, editorial-emphasis, clip-wipe, gradient-fill, neon-glow, glitch-rgb, matrix-decode, particle-burst, weight-shift, pill-karaoke, highlight, blend-difference…) | words; emphasis_words → word--emphasis; palette; font; timing | a motif none of the 18 cover |
| charts | data-chart (bar+line, staggered, value labels) | edit the data array + headline/subtitle; --bg/--text | pie/donut, bar-chart-race, ring/% |
| stat | apple-money-count (finance: $ counter + burst + SFX) or our generic stat-motion (any %/number + ring) | target, prefix/suffix, label, palette | — |
| maps/geo | vector lane: us-map (+bubble/hex/flow), world-map, spain-map + hand-author geo-highlight/geo-flow/flag-borders/pin-rollout (NOT in registry — build per categories/maps/module.md). basemap lane (real satellite/dark / zoom-to-place): bake via categories/maps/bake-basemap.mjs → <video> + geo-aligned SVG overlay (border draw-on + colour-block fills) | regions/data, palette, callouts/connectors, basemap style | (basemap lane now covers real imagery + zoom-to-address — was the gap) |
| diagram | flowchart (SVG connectors + nodes) | nodes, edges, labels | — |
| code / code-reveal | one of the 9 Code Animations blocks: code-typing (live typing), code-diff (a change), code-morph (a refactor), code-highlight (spotlight a line), code-scroll (walk a file), code-snippet-flight (assemble); GPU hero reveals code-3d-extrude / code-shader-dissolve / code-particle-assemble for a title-card moment; for a static themed code/terminal window use a code-snippet-* block | the baked code string / diff / theme | a code motion none cover |
| brand reveal | logo-outro (piece assembly + glow + tagline + URL pill) | logo asset, tagline, URL, palette | — |
| transitions | 15+ shader transitions (domain-warp, whip-pan, sdf-iris, glitch…) | direction, timing | — |
| social overlay | ig / tiktok / yt / x / reddit / spotify / macos cards | handle, metrics, avatar | — |
| polish | grain-overlay, vignette, shimmer-sweep, texture-mask-text | intensity | — |
| asset-fusion (RWA) | annotation kit from north-korea-locked-down — scribble-circle draw-on, pop-up pill + pointer, red-wash, scanline, corners, camera push; + us-map-bubble callouts/connectors | asset, annotation position (element_positions), label, palette eyedropper'd from the asset | NET-NEW (not in catalog): the affordance binding — asset geometry _becomes_ the chart axis (straw → gauge) |
Reuse mechanics
npx hyperframes add <block>→ source lands incompositions/<block>.html; inline it or reference viadata-composition-src; customize content/data/palette/positions in place.- Blocks ship at fixed canvases (1920×1080 / 1080×1920 / 1080×1080) — match or adapt.
- Blocks already follow the HF contract (paused timeline, seek) — keep it.
Asset-fusion: borrow + net-new (the only real net-new IP)
Borrow north-korea-locked-down's annotation _language_; the net-new is the RWA fusion _logic_ (see the RWA study):
- Classify the data type; read the asset's geometric affordance — linearity → timeline/gauge, volume/texture → pie, height → bar, container → exploded view.
element_positions— measure the asset's feature (center / extent / safe-zones / avoid-zones). This is the reverse-engineer step (vision); in the prototype it's hand-estimated.- Eyedropper palette from the asset (never generic #FFF/#000).
- Two layers — asset (z0, full-bleed) + data graphics fused to its geometry (z1+), anchored by
element_positions; connectors/scribble physically tie the data to the asset; the asset stays visible.
asset-fusion — category module (search-driven · the net-new IP)
A real asset's geometry _becomes_ the chart — RWA diegetic fusion (the straw becomes a gauge; a glass's liquid becomes a pie). Not in any catalog; the genuinely net-new capability. ~5–8s.
Source (Step 2)
Search or generate one hero asset with strong geometric affordance. asset_needs: { kind: image, query|generate, treatment: cutout|none }. Freeze it project-local.
Plan (Director Part 2) — the fusion logic
- Classify the data type (temporal / quantitative / proportion / spatial).
- Read the asset's geometric affordance — linearity → timeline/gauge · volume/texture → pie · height → bar · container → exploded view.
element_positions: GROUND with the locate protocol — never eyeball pixel coords. Readgrounding/PROTOCOL.mdand run the loop withnode grounding/locate.mjs(overlay→ read strips →region→ read crop →final→mark+ verify). Zero keys/deps assumed (node + ffmpeg only); the optionalautofast path exists only whenGEMINI_API_KEYhappens to be set. Why: eyeballing put rings off-subject (~16–24% center error on weak vision models); the grid loop pulls it to ~2–4% (measured E2E: eyeball 6.5% → protocol 2.3% avg center error, no case worse).- Eyedropper palette from the asset (never generic #FFF/#000).
Highlight + circle recipe (the common case)
"Ring / spotlight object X in a real image" → use the drop-in template samples/asset-fusion/_ref-circle-highlight.html: set CFG.box (from the locate protocol), CFG.label, CFG.asset, EVEN CFG.W/H, CFG.mode (full = ring+connector+label+brackets+scanlines, circle = ring only). It computes the radial wash, the amber double over-stroke ring, connector, callout, and corner-bracket reticle from the box. The whole pipeline is: locate (PROTOCOL.md) → fill template → render.
Render gotchas (codified — skipping these breaks the render)
- EVEN width & height — odd width _or height_ (e.g. 1400×933) →
ffmpegencode fails / distorts. Resize the asset/stage to even dims (1400×932). data-width/data-heightmust be STATIC HTML attrs on the stage — the renderer's StaticGuard reads them at compile time, before JS runs. Setting them viasetAttributeis too late → render falls back to portrait 1080×1920 and distorts. (The circle-highlight template now hard-codes them; keep them equal toCFG.W/H.)- Draw-on (
stroke-dashoffset) must beautoAlpha:0-gated —getTotalLength()can read 0 before layout → dash disabled → a solid line shows at t=0. Gate every draw-on element withautoAlpha:0until it draws, and fall backgetTotalLength() || <const>. - CSS var tween scope —
gsap.to(":root", {"--x":..})won't reach an element that has its own inline--x; tween the var on the element itself. - No camera push under a fixed overlay — scaling the image while the ring/wash stay fixed drifts the target out of the ring. Either skip the push or scale the whole scene together.
- Lossless delivery:
--format mov(ProRes);mp4is lossy.
Vocabulary / leans on
- Borrow the annotation kit from registry
north-korea-locked-down(hand-drawn scribble circle draw-on, pop-up label + pointer, editorial wash, camera push). - Primitives: gauge fill / marker-rise along the affordance · connector (data → asset point) · diegetic chart fused to the asset's geometry.
- Adapt the diegetic chart to each asset's affordance (read the geometry, fuse the data into it).
Build (reuse-first + hand-author the affordance)
Two layers: asset (z0, full-bleed) + data graphics (z1+) fused to its geometry, anchored by element_positions; connectors/scribble physically tie the data to the asset; asset stays visible. Reference impl: the prototype fusion-demo/index-annotated.html (straw → gauge + borrowed annotation kit).
charts — category module
Animated data-viz from data. Asset-free (the "input" is the data). "One chart, one message" for short durations.
Plan (Director)
content: { type: bar|line|pie|race|pct, data[], labels[], headline, axes: bool }. For race, data must be cumulative/time-staged.
Vocabulary / leans on
- Block:
data-chart(animated bar + line, staggered reveal, value labels — proven: borrowed + customized + rendered to MP4 in the prototypecharts-demo). - Gaps (hand-author): pie / donut, bar-chart-race, ring/% —
data-chartdoesn't cover these. Use D3/visx for data→geometry + GSAP for motion. - Signature animations: bar stagger-grow · line
stroke-dashoffsetdraw-on · pie radial sweep · ring fill · KPI count-up · race reorder.
Build (reuse-first)
Reuse data-chart: npx hyperframes add data-chart → edit the data arrays + scales + headline/labels + palette in place (its data is baked in the script, not a --variables flag). Axes hidden by default; show muted only when magnitude is the message. Determinism: drive any animation from the seek clock, never wall-clock.
Dashboard-skeleton variant
For a product-dashboard case: lay out a skeleton dashboard — a top bar with a real test logo (e.g. the hyperframes logo in samples/_assets/) + a title, then a grid of 3–4 KPI cards (each a stat count-up) + one data-chart panel. Reveal order: header/logo in → cards stagger in → the chart animates. Composes the stat + charts primitives inside a dashboard frame; the logo is a frozen project-local asset.
kinetic-type — category module
Text is the hero; typography + motion carry the message. Usually asset-free (asset_needs: []).
Plan (Director)
- Style first. If the project has a
design.md/frame.md(precedence:frame.md→design.md→DESIGN.md), READ it and use its exact palette / fonts / constraints — do not invent. No spec → pick a named style, or ask: mood + light/dark + any brand color/font. (Style is an input the case must exercise; the same shot in twodesign.mds should look different.) - Segment the copy into scenes by meaning / breath (EN ~3–7 words; ZH ~4–12 chars). Tag each scene Hook → Build → Punch → Resolve.
- 1–2
emphasis_wordsper scene. Per scene:emotion+motion(free-form) +beats.
Vocabulary
Motion primitives + registry blocks: references/motion-vocabulary.md (slide / scale / fade / blur / typewriter / word_reveal / wave / bounce / slam / scale_pulse / shake / glow / color_shift, plus the 18 caption-* blocks).
Build (reuse-first)
- Prefer a *`caption-
block** when one fits (caption-kinetic-slam/caption-editorial-emphasis/ …):npx hyperframes add+ set words /emphasis_words` / palette / font. - Else hand-author: one full-duration
.clip; a.groupper scene (flex-centered); words as spans;gsap.from()entrances per the scenemotion; emphasis words →glow/scale_pulseon the beat; seek-safe reveal (autoAlpha). Honorreferences/builder-contract.md. - Reference impl: prototype
v0-text-motion-demo+pipeline-demo.
logo-reveal — category module
A logo sting / brand lockup. The logo is user-supplied (asset_needs = one logo source, not a search). ~3–5s. Often export: alpha-overlay (sting to drop on other footage).
Plan (Director)
content: { logo: <asset path>, tagline, url }. Envelope: brand palette (or eyedropper from the logo), elegant pacing.
Vocabulary / leans on
- Block:
logo-outro(piece-by-piece assembly + glow bloom + tagline fade + URL pill). - Rules:
rules/svg-path-draw(draw-on for vector logos) ·rules/scale-swap-transition·rules/3d-text-depth-layers. - Primitives: draw-on / mask-reveal / particle-assemble ·
glowbloom ·underline_sweep· hold.
Build (reuse-first)
Reuse logo-outro + swap logo / tagline / url / palette; or hand-author: place the logo at its hero frame (CSS), reveal via draw-on (SVG stroke-dashoffset) or mask/scale, add a glow bloom + accent underline sweep, hold. For an SVG logo, prefer draw-on; for a raster logo, mask/scale + glow. Transparent bg when exporting as an overlay.
lower-thirds — category module
Name/title bars, callouts, social overlays — graphics meant to sit over other footage. Asset-free (+ optional logo). Usually export: alpha-overlay (transparent). ~3–6s (or loop/hold).
Plan (Director)
content: { name, role, position (lower-left / lower-third / corner), brand_colors[] }. Default export: alpha-overlay.
Vocabulary / leans on
- Blocks:
caption-*(pill-karaoke, neon-accent, editorial-emphasis) + registry overlay blocks (instagram-follow,tiktok-follow,yt-lower-third,x-post,spotify-card,macos-notification). - Primitives: slide/wipe-in · bar reveal ·
glow· fade/slide-out.
Build (reuse-first)
Reuse the closest overlay/caption block + set name / role / handle / brand colors / position; or hand-author a bar that wipes in (scaleX from 0, transform-origin:left) with text sliding up behind it, hold, slide out. Transparent background (export: alpha-overlay → render --format webm/mov) so it composites over footage. Keep it in the title-safe lower band.
// bake-basemap.mjs — canonical basemap-lane helper for the `maps` skill.
//
// WHAT IT DOES (and why baking at all): drives MapLibre in headless Chrome to record ONLY the
// real-imagery basemap as an MP4 (camera zoom→hold), and projects each requested country's border
// to SCREEN coordinates at the held view. The HF composition then plays the MP4 on track 0 and
// animates a *live* SVG overlay (border draw-on, colour-block / flag fills, labels, pins) from the
// exported `coords.json`. Borders/fills are NOT baked into the video — they stay editable in HF.
//
// Why bake the imagery at all (this is the real reason, not "smoothness"): HF forbids render-time
// network and requires deterministic output. Live raster tiles re-fetch every render and can change
// → non-deterministic. Baking FREEZES the imagery into pixels = deterministic + offline-reproducible.
// (Exposing the engine's per-frame `onBeforeCapture` hook would let MapLibre run live and smooth, but
// it would NOT remove the need to freeze tiles for determinism — so this bake step stays relevant.)
//
// PARAMETRIC — drive everything by env. Example (Brazil + Argentina on satellite):
// NAME=br-ar STYLE=satellite COUNTRIES="Brazil:#22d3ee,Argentina:#f59e0b" \
// CENTER="-60,-25" ZSTART=2.4 ZEND=3.4 FPS=30 DUR=5 node bake-basemap.mjs
// Then encode frames-<NAME>/f%04d.png → <NAME>.mp4 (all-intra: -g 1) and feed <NAME>-coords.json
// to the HF composition.
import puppeteer from "puppeteer-core";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { mkdirSync, writeFileSync, readdirSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- resolve Chrome dynamically (no hardcoded machine path) ---
function resolveChrome() {
if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;
const exe = process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
const base = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
if (existsSync(base)) {
for (const v of readdirSync(base).sort().reverse()) {
// lexical sort; any working binary is fine
try {
for (const inner of readdirSync(join(base, v))) {
const bin = join(base, v, inner, exe);
if (existsSync(bin)) return bin;
}
} catch {
/* skip */
}
}
}
for (const c of [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
])
if (existsSync(c)) return c;
throw new Error(
"Chrome not found. Set CHROME=/path/to/chrome-headless-shell, or install one:\n" +
" npx puppeteer browsers install chrome-headless-shell",
);
}
// --- params (all overridable by env) ---
const NAME = process.env.NAME || "basemap";
const STYLE = process.env.STYLE || "satellite"; // satellite | dark | light | raw {z}/{x}/{y} template
const CENTER = (process.env.CENTER || "2.6,46.6").split(",").map(Number);
const ZSTART = +(process.env.ZSTART || 4.2);
const ZEND = +(process.env.ZEND || 5.4);
const PITCH = +(process.env.PITCH || 0);
const BEARING = +(process.env.BEARING || 0);
const FPS = +(process.env.FPS || 30),
DUR = +(process.env.DUR || 5),
N = Math.max(1, Math.round(FPS * DUR));
const HOLD = +(process.env.HOLD || 0.5); // p∈[0,1] at which the zoom finishes; camera holds after
const MARGIN = (process.env.KEEPMARGIN || "16,13").split(",").map(Number); // [lon°,lat°] keep-box around each country's mainland
// COUNTRIES="Name:#hex,Name:#hex" — borders to project (optional; omit for a pure zoom-to / pin shot)
const COUNTRIES = (process.env.COUNTRIES || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const [name, color] = s.split(":");
return { name, color: color || "#38bdf8" };
});
// fail fast on bad numeric env — otherwise NaN silently bakes zero/garbage frames and still prints "done"
for (const [k, v] of Object.entries({
"CENTER.lng": CENTER[0],
"CENTER.lat": CENTER[1],
ZSTART,
ZEND,
PITCH,
BEARING,
FPS,
DUR,
HOLD,
}))
if (!Number.isFinite(v))
throw new Error(
`bad numeric env: ${k}=${v} — check CENTER="lng,lat" / ZSTART / ZEND / FPS / DUR`,
);
// IMPORTANT: tileSize:256 matches Esri/CARTO raster endpoints. MapLibre's INTERNAL world width is
// 512·2^zoom regardless — a 512px (@2x/retina/vector) tile source needs tileSize:512 or every zoom
// level is off by one. Keep 256 for these raster sources.
const TILES =
{
satellite:
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
dark: "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
light: "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
}[STYLE] || STYLE; // STYLE may also be a raw {z}/{x}/{y} template
const OUT = process.env.OUT || process.cwd(); // artifacts → workspace (cwd), NOT the installed skill dir
const framesDir = join(OUT, "frames-" + NAME);
mkdirSync(framesDir, { recursive: true });
// Deps PINNED exact (mutable @5/@2 majors would drift the bake over time).
const PAGE = `<!doctype html><html><head>
<link href="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3.1.0/dist/topojson-client.min.js"></script>
<style>*{margin:0}html,body{width:1920px;height:1080px;overflow:hidden;background:#05070d}#map{width:1920px;height:1080px}.maplibregl-control-container{display:none!important}</style>
</head><body><div id="map"></div><script>
var CENTER=${JSON.stringify(CENTER)}, ZSTART=${ZSTART}, ZEND=${ZEND}, PITCH=${PITCH}, BEARING=${BEARING}, HOLD=${HOLD};
var MARGIN=${JSON.stringify(MARGIN)}, WANT=${JSON.stringify(COUNTRIES)};
var map=new maplibregl.Map({container:"map",style:{version:8,projection:{type:"mercator"},
sources:{s:{type:"raster",tiles:[${JSON.stringify(TILES)}],tileSize:256,maxzoom:19}},
layers:[{id:"bg",type:"background",paint:{"background-color":"#05070d"}},{id:"s",type:"raster",source:"s"}]},
center:CENTER,zoom:ZSTART,pitch:0,bearing:0,interactive:false,attributionControl:false,fadeDuration:0,preserveDrawingBuffer:true,maxTileCacheSize:6000});
function ease(x){return x<0.5?4*x*x*x:1-Math.pow(-2*x+2,3)/2;} // easeInOutCubic (= Remotion interpolate+Easing)
function camAt(p){ var t=ease(Math.min(1,p/HOLD)); return {center:CENTER, zoom:ZSTART+(ZEND-ZSTART)*t, pitch:PITCH*t, bearing:BEARING*t}; }
function ringCentroid(r){ var sx=0,sy=0; for(var k=0;k<r.length;k++){sx+=r[k][0];sy+=r[k][1];} return [sx/r.length, sy/r.length]; }
// Keep the polygons in a lon/lat box around the country's MAINLAND (the vertex-richest polygon),
// dropping far-flung overseas territories that would blow up the bbox. Generalizes per subject —
// no continent-specific constant. Keeps near islands (Corsica, Sicily); drops Guiana, Alaska, Hawaii.
function mainland(f){
if(!f) return f;
if(f.geometry.type!=="MultiPolygon"){ f.__anchorRing=f.geometry.coordinates[0]; return f; } // Polygon: outer ring
var polys=f.geometry.coordinates, anchor=polys[0], amax=-1;
polys.forEach(function(poly){ if(poly[0].length>amax){amax=poly[0].length;anchor=poly;} });
var ac=ringCentroid(anchor[0]);
var kept=polys.filter(function(poly){ var c=ringCentroid(poly[0]); return Math.abs(c[0]-ac[0])<=MARGIN[0] && Math.abs(c[1]-ac[1])<=MARGIN[1]; });
var nf={type:"Feature",properties:f.properties,geometry:{type:"MultiPolygon",coordinates:kept}}; nf.__anchorRing=anchor[0]; return nf;
}
function lonSpan(f){ var mn=1e9,mx=-1e9; (f.geometry.type==="Polygon"?[f.geometry.coordinates]:f.geometry.coordinates).forEach(function(poly){poly[0].forEach(function(c){if(c[0]<mn)mn=c[0];if(c[0]>mx)mx=c[0];});}); return mx-mn; }
// ANTIMERIDIAN unwrap (Russia/Fiji/NZ): make all lon contiguous around the camera-center ref so a
// feature touching ±180° doesn't smear when map.project() runs per-vertex (mercatorX is linear and
// accepts out-of-range lon, so 181 sits just east of center, not far-west at -179). Mutates in place;
// __anchorRing shares the same arrays so it's covered.
function unwrapLon(f, ref){ if(!f) return; function fix(r){ for(var i=0;i<r.length;i++){ var lon=r[i][0]; while(lon-ref>180)lon-=360; while(lon-ref<-180)lon+=360; r[i][0]=lon; } }
var g=f.geometry; if(g.type==="Polygon") g.coordinates.forEach(fix); else g.coordinates.forEach(function(poly){ poly.forEach(fix); }); }
var FEATS=[]; window.__warn=[];
window.__ready=new Promise(function(res){ map.on("load", function(){
if(!WANT.length){ res(); return; }
fetch("https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/countries-110m.json").then(function(r){return r.json();}).then(function(w){
var fc=topojson.feature(w,w.objects.countries);
WANT.forEach(function(want){
var f=fc.features.filter(function(x){return x.properties.name===want.name;})[0];
if(!f){ window.__warn.push("country not found in world-atlas: "+want.name); return; }
f=mainland(f);
unwrapLon(f, CENTER[0]); // antimeridian: unwrap lons around the camera ref (CENTER must be near the subject) before projecting
if(lonSpan(f)>180) window.__warn.push(want.name+" spans >180° lon even after unwrap — projection may still smear.");
FEATS.push({name:want.name, color:want.color, f:f});
});
res();
});
});});
window.__setCam=function(p){ map.jumpTo(camAt(p)); };
// returns true if the idle event did NOT fire within ms (i.e. tiles may be incomplete)
// returns true only if tiles are GENUINELY not loaded at timeout — CARTO/Esri idle is flaky and
// often never fires even when every tile is painted, so check areTilesLoaded() before crying timeout.
window.__waitIdle=function(ms){ return new Promise(function(res){ var done=false; function fin(t){if(done)return;done=true;res(t);} map.once("idle",function(){fin(false);}); setTimeout(function(){ fin(!map.areTilesLoaded()); }, ms||9000); }); };
function featurePath(f){ function ring(r){ return r.map(function(c,i){ var p=map.project(c); return (i?"L":"M")+p.x.toFixed(1)+" "+p.y.toFixed(1); }).join(" ")+"Z"; }
var g=f.geometry,d=""; if(g.type==="Polygon") g.coordinates.forEach(function(r){d+=ring(r);}); else g.coordinates.forEach(function(poly){poly.forEach(function(r){d+=ring(r);});}); return d; }
function bboxOf(f){ var mnx=1e9,mny=1e9,mxx=-1e9,mxy=-1e9;
function eat(r){ r.forEach(function(c){ var p=map.project(c); if(p.x<mnx)mnx=p.x; if(p.y<mny)mny=p.y; if(p.x>mxx)mxx=p.x; if(p.y>mxy)mxy=p.y; }); }
var g=f.geometry; if(g.type==="Polygon")g.coordinates.forEach(eat); else g.coordinates.forEach(function(poly){poly.forEach(eat);});
return {x:+mnx.toFixed(1),y:+mny.toFixed(1),w:+(mxx-mnx).toFixed(1),h:+(mxy-mny).toFixed(1)}; }
window.__project=function(){ return {
view:{center:CENTER, zoom:ZEND, pitch:PITCH, bearing:BEARING},
countries: FEATS.map(function(e){ var lc=ringCentroid(e.f.__anchorRing); var lp=map.project(lc);
return { name:e.name, color:e.color, d:featurePath(e.f), bbox:bboxOf(e.f), label:{x:+lp.x.toFixed(1),y:+lp.y.toFixed(1)} }; }),
}; };
</script></body></html>`;
// --no-sandbox is intentional: trusted Source-time bake, headless, often root/CI; deps are version-pinned above.
const browser = await puppeteer.launch({
executablePath: resolveChrome(),
headless: true,
args: [
"--no-sandbox",
"--hide-scrollbars",
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--enable-webgl",
"--window-size=1920,1080",
],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
await page.setContent(PAGE, { waitUntil: "load" });
await page.evaluate(() => window.__ready);
for (const w of await page.evaluate(() => window.__warn)) console.warn(`[${NAME}] WARN: ${w}`);
console.log(
`[${NAME}] ready (${STYLE}); baking ${N} frames, zoom ${ZSTART}→${ZEND} hold@p=${HOLD}, ${COUNTRIES.length} border(s)`,
);
let coords = null;
const timeouts = [];
for (let i = 0; i < N; i++) {
const p = N === 1 ? 1 : i / (N - 1);
await page.evaluate((pp) => window.__setCam(pp), p);
const timedOut = await page.evaluate((ms) => window.__waitIdle(ms), 9000);
if (timedOut) {
timeouts.push(i);
console.warn(`[${NAME}] idle TIMEOUT at frame ${i} — tiles may be incomplete`);
}
await page.screenshot({
path: join(framesDir, `f${String(i).padStart(4, "0")}.png`),
clip: { x: 0, y: 0, width: 1920, height: 1080 },
optimizeForSpeed: true,
});
if (p >= HOLD && !coords && COUNTRIES.length)
coords = await page.evaluate(() => window.__project()); // capture at first hold frame
if (i % 20 === 0 || i === N - 1) console.log(` [${NAME}] ${i + 1}/${N}`);
}
// encode frames → all-intra MP4 (every frame seekable for HF); fall back to printing the command if ffmpeg is absent
const mp4 = join(OUT, NAME + ".mp4"),
pat = join(framesDir, "f%04d.png");
const ff = spawnSync(
"ffmpeg",
[
"-y",
"-framerate",
String(FPS),
"-i",
pat,
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-g",
"1",
"-crf",
"16",
"-movflags",
"+faststart",
mp4,
],
{ stdio: "ignore" },
);
if (ff.status === 0) console.log(`[${NAME}] encoded → ${mp4}`);
else
console.warn(
`[${NAME}] ffmpeg unavailable (status ${ff.status}) — encode manually:\n ffmpeg -y -framerate ${FPS} -i ${pat} -c:v libx264 -pix_fmt yuv420p -g 1 -crf 16 -movflags +faststart ${mp4}`,
);
if (coords) {
writeFileSync(join(OUT, NAME + "-coords.json"), JSON.stringify(coords));
console.log(
`[${NAME}] coords written: ${coords.countries.map((c) => c.name + "(" + c.d.length + "ch)").join(", ")}`,
);
}
if (timeouts.length) {
// FAIL LOUD: the asset exists but is suspect — don't let a half-loaded bake pass silently
console.error(
`[${NAME}] ${timeouts.length}/${N} frame(s) hit the idle timeout (frames ${timeouts.slice(0, 8).join(",")}${timeouts.length > 8 ? "…" : ""}). The basemap MP4 may have INCOMPLETE tiles. Re-run with a slower zoom / larger timeout / check the tile server.`,
);
process.exitCode = 1;
} else {
console.log(`[${NAME}] done — all ${N} frames reached map idle (complete tiles).`);
}
} finally {
await browser.close();
}
maps — category module
Geographic motion: highlight regions, connect places, zoom to a location. First decision: does the shot need a real basemap (satellite/street/terrain imagery, globe, or zoom to a real address)? That picks the lane.
Plan (Director)
Set content.lane first:
- vector (default) — stylized region shapes, no real imagery. Native + live in HF, cheap.
asset_needs: []. - basemap — needs real satellite/dark tiles, globe, or zoom-to-real-place.
asset_needs: [{ type: "map-bake", … }]. Bake the imagery in Source: HF forbids render-time network and requires determinism, so live tiles (which re-fetch and can change per render) can't be the imagery layer — baking freezes it (and is smooth as a bonus). See Basemap lane + Determinism.
content: { lane, shot: highlight|flow|choropleth|labels|flag|pin-rollout|zoom-to, regions[], points[], basemap: satellite|dark, palette, headline, overlays: [label|pin|callout-card] }.
overlays are independent of shot and work in both lanes. callout-card = a pinned card (flag chip + stat + progress bar) — this is the "documentary popup" (top #13); compose it on any zoom-to/highlight shot rather than treating it as its own shot.
Vocabulary / leans on
Vector lane (D3 + TopoJSON — reuse the existing map family, don't duplicate):
- Reuse:
us-map(+bubble/hex/flow),world-map,spain-map. - Hand-author these (NOT in the catalog — build per the signatures below, don't expect an
add):geo-highlight(N countries colored + labels + border pulse),geo-flow(world arcs / hub network),flag-borders(flag clipped to a country),pin-rollout(cities pulse in sequence + counter). - Signature: country fill-in stagger · pin drop + pulse ring · arc
stroke-dashoffsetdraw-on + flyer · choropleth color reveal · viewBox zoom.
Basemap lane (MapLibre, baked in Source — validated pipeline, bake-basemap.mjs, fully env-parametric — runs for any country, not just the prototypes):
- Bake
bake-basemap.mjs(puppeteer + MapLibre; env-driven:NAME STYLE COUNTRIES CENTER ZSTART ZEND PITCH BEARING FPS DUR): drive the camera zoom→hold (easeInOutCubic) andawait map.once('idle')before every frame so each frame has complete tiles (RemotiondelayRendertechnique → no tile pop). The helper resolves Chrome itself, pins deps exactly, derives the overseas-territory filter per subject, and fails loud on idle-timeout (suspect frames → non-zero exit). _External deps: the pinned CDN libs (maplibre/topojson/world-atlas) + the Esri/CARTO tile endpoints are third-party — re-verify availability + ToS on any version bump._preserveDrawingBuffer:true,fadeDuration:0. The helper then encodes the frames to an all-intra MP4 itself (ffmpeg -framerate FPS -i f%04d.png -c:v libx264 -g 1 -pix_fmt yuv420p) and writes everything to$OUT(defaultcwd), not the skill dir →<NAME>.mp4+<NAME>-coords.json. - Export geo→screen at the hold view:
map.project()each requested country →<NAME>-coords.json={ view, countries: [{ name, color, d (SVG path), bbox, label }] }. The camera holds static after the zoom, so these paths stay pixel-aligned.labelis an approximate anchor (vertex-average of the mainland ring — for concave countries nudge per Legibility). Consumecoords.countries[i]; thesmooth-frde2/smooth-flagexample dirs predate this helper and use a flat{fr,de,…}shape, so adapt their wiring. - Builder:
<video>basemap on track 0 + an SVG overlay that, during the hold, animates country borders (stroke-dashoffsetdraw-on) + fills (colour-block reveal) + labels/pins/cards — all geo-aligned viacoords.json. This reproduces the Hera "satellite + animated coloured borders/callouts" look. - The same projected path also doubles as an SVG
clipPath: clip a flag (or any texture) into the real border for _flag-in-borders_ — a richer fill than flat colour, over real map context (validated: France tricolor over a dark basemap,smooth-flag). Export the feature's screen bbox alongside its path so the flag stripes/texture can be sized to the country. - Stretch / data gaps (extend
bake-basemap.mjsas needed — the eval agents did): a globe intro ("start from the globe") = MapLibreprojection:{type:'globe'}for the opening phase, easing to mercator at the target (the helper defaults to mercator). Sub-national regions (states/provinces) aren't in world-atlas (country-only) → use a Natural Earth admin-1 TopoJSON, or project centroids as pins (pin-rollout).
Build (reuse-first)
Vector: npx hyperframes add <block> → edit regions/data/palette in place. Basemap: baked map.mp4 as track-0 <video>, bind overlays to anchors.
Restraint (no cheese — this is the #1 way auto-built maps go wrong): every animated element must serve the message — region, connector, label, pin, camera. NO decorative ambient glows, background light blobs, floating particles, lens flares, or gratuitous bloom. Motion = a continuous camera move (viewBox push/zoom) + purposeful, overlapping element reveals — not a light show. Palette: color must carry meaning — a data scale (choropleth), categorical fills that distinguish regions (political map), or 1–2 accents for the subjects (highlighted countries / route) over neutral everything-else. Don't add color as decoration (a country amber just for contrast, a glow for "energy"). The frame should read like a clean broadcast map, not a screensaver.
Legibility (hard rule): offset labels from the highlighted shape and from each other; clamp to the safe area; a callout pill must not sit on another label or a border (the eval surfaced a DE/PL "Oder–Neisse" pill overlapping the POLAND label). A key element stays readable ≥~0.3s.
Attribution (hard rule): real basemap imagery carries usage terms — bake a credit element into the composition whenever a basemap is on screen (Esri satellite → "Esri, Maxar, Earthstar Geographics"; CARTO → "© CARTO, © OpenStreetMap"). A small low-corner label (see smooth-jp). Non-negotiable for anything published.
Determinism (hard rules — each one bit us in the prototypes):
- Drive everything from the seek clock; never
tl.callfor stateful updates (counters, text) → proxy tween +onUpdate(tl.call freezes the timeline under HF seek). - SVG zoom = animate viewBox (don't hand-compute group transform origins).
- Centered overlays (cards/labels using
transform: translate(-50%,…)to center): animate opacity only, or wrap in an outer centered div — GSAP animatingy/scaleoverwrites the whole transform and kills the centering. - Country geometry: filter to the polygon(s) in a lon/lat box around the subject — world-atlas bundles overseas territories that blow up the bbox (France + Guiana). Keep near islands (Corsica, Sicily), drop far ones. (
bake-basemap.mjsanchors on the vertex-richest polygon ±KEEPMARGIN— no continent-specific constant.) - Tile world-scale: MapLibre's internal world width is
512·2^zoomregardless of the rastertileSize. Esri/CARTO raster →tileSize:256(correct); a 512px / @2x / retina / vector source needstileSize:512or every zoom level is off by one (this silently over-zoomed a bake once — France overflowed the frame top-to-bottom). - Antimeridian: a feature crossing ±180° (Russia, Fiji, NZ) smears under per-vertex
map.project().bake-basemap.mjsunwraps longitudes around the camera-center ref before projecting, which handles it; it still warns if a feature spans >180° even after unwrap. - Smoothness = per-frame complete tiles + eased camera. In the bake,
await map.once('idle')before each screenshot (= RemotiondelayRender) and ease the camera witheaseInOutCubic(= interpolate+Easing).preserveDrawingBuffer:true,fadeDuration:0, largemaxTileCacheSize. - Overlay alignment: project feature borders at a held camera and only animate the overlay during the hold — a moving camera + a fixed projected path drift apart.
- Pre-hold hidden state: an overlay revealed _at_ the hold must be
gsap.setto its hidden state at build time (scaleX:0, fullstroke-dashoffset,opacity:0) — a barefromTodoes not apply its "from" until the tween starts, so the element otherwise shows at its natural (visible, mispositioned) state during the zoom-in. - Why bake at all (the real reason — not just smoothness): baking freezes the imagery into deterministic pixels. Live raster tiles re-fetch every render and can change, and render-time network is forbidden. MapLibre _does_ render live in HF (just janky: tiles pop, deep zooms outrun loading); exposing the engine's per-frame
onBeforeCapturehook (it exists —frameCapture.ts:~1250) would make live smooth — but it would not remove the need to freeze tiles for determinism + offline reproducibility. SoonBeforeCapturewould replace the _smoothness_ role of baking, not the _freeze_ role.
Out of scope
3D photorealistic landmarks (Cesium territory) · per-country / per-template blocks (parametrize instead) · charts (→ charts). (In-engine _live_ MapLibre is possible but janky today — bake instead; revisit if the engine gains a per-frame ready hook.)
Register
director.md classifier line (the lane fork) + catalog-map.md maps/geo row (add the basemap lane). Phase pipeline untouched.
news — category module (search-driven)
Search a real news article → animate it as an article-highlight — a faithful HF port of the centered-emphasis technique. Signature motion: the article text is laid out at a readable size (NOT zoomed), slides + fades up, then the keyword is highlighted in place — a marker band grows left→right behind it. No blow-up zoom. ~5–7s.
Two layouts share that one highlight core — pick by aspect + whether there's a logo / person to feature:
| Layout | Aspect | Use when | Has |
|---|---|---|---|
| A · centered-emphasis | 9:16 / 1:1 | text-only, social vertical | kicker + one centered sentence + keyword highlight |
| B · full article | 16:9 | there's a real outlet logo and/or a person in the story | logo + date + multi-line headline highlight + dek + outlet + person cutout |
Source (Step 2)
RWA / web search (or hyperframes capture) → a real article. The Director extracts the keyword (1–2 words / a number / a name — the hook) and, for Layout B, also a brand logo, a date, and a person photo. asset_needs: { kind: news|web|image, query } — request the logo (Wikimedia / simple-icons) and the person photo (Wikimedia) as separate asset queries. Run the person photo through hyperframes remove-background <in> -o <out>.png to get a transparent cutout.
The highlight core (shared by both layouts) — the marker-band technique
The keyword highlight is a marker band grown L→R via background-size (NOT a transform:scaleX bar):
.hl {
--hlw: 0%;
background-image: linear-gradient(var(--hl), var(--hl)); /* --hl: rgba(250,222,99,.6) */
background-repeat: no-repeat;
background-position: 0 72%;
background-size: var(--hlw) 64%;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}box-decoration-break:clone makes the band wrap across line breaks seamlessly — a multi-word keyword spanning 2–3 lines still highlights continuously (the old scaleX bar could not). GSAP tweens the CSS var --hlw 0%→100%, swept on AFTER the text settles — never pre-applied. Optionally scale the duration with keyword length ~0.5–1.5s.
Layout A — centered-emphasis (9:16, text-only) — Builder
- Lay out the key SENTENCE at a readable size — do NOT zoom. Paper-light stage; an outlet
#kicker(e.g. "BBC NEWS · TECHNOLOGY", brand accent) at top; the key sentence centered, serif, ~70–76px / 700 / line-height ~1.4,max-width ≈ 900. The whole sentence stays legible; the keyword is emphasized _within_ it. - Wrap the keyword inline:
… raised <span class="hl" id="kw">$750M</span> at …. - Timeline (seek-safe, paused): kicker fades+slides in → sentence slides+fades up (
set{autoAlpha:0,y:52}→to{autoAlpha:1,y:0,duration:0.8,ease:"power3.out"}, no zoom) → keyword highlight grows (--hlw 0→100%, after the sentence settles) → gentle hold (scale 1.012).
Reference: samples/news/_ref-centered-emphasis.html (+ news/v2-*).
Layout B — full article (16:9, logo + person) — Builder
The editorial article-card layout (logo top-left + date + big serif headline with a multi-line keyword highlight + dek + outlet bottom-left + a background-removed person cutout bottom-right). 1920×1080, paper-light gradient stage, Georgia serif.
- Logo top-left (
#logo, ~64px) — inline the outlet/brand SVG,fill:currentColor, setcolorto the mark's own hue (or near-black for a monochrome mark). Date below it (#date, ~30px, muted). - Headline (
#headline, Georgia 96px / 700,width≈1080,line-height 1.16) with the keyword wrapped in<span class="hl" id="kw">— the highlight band sweeps across all wrapped lines of the phrase. - Dek (
#dek, Helvetica ~38px gray) under the headline; outlet (#outlet, Georgia bold ~50px) bottom-left. - Person cutout (
#subject, the bg-removed PNG,height≈860,right:40; bottom:0,drop-shadow) — slides up from bottom-right as the hero beat. - Timeline (seek-safe, paused): logo
back.out@0.15 → date @0.4 → subject slides up (set{autoAlpha:0,x:90,y:60}→to{autoAlpha:1,x:0,y:0,duration:0.85,ease:"power3.out"}) @0.45 → headline @0.7 → dek @1.5 → outlet @1.9 → keyword highlight (--hlw 0→100%,duration:0.8) @2.2. duration ~6.5s.
Reference: samples/news/_ref-article-layout.html (a real article rendered with a real outlet logo + a background-removed person + a real story).
Critical
Highlight is grown in place, swept on AFTER the text — never pre-applied. The text stays readable — do NOT zoom the keyword to fill the frame (team feedback 2026-06-09: zoom loses context and reads wrong; the technique never zooms — it presents the text and highlights the word). For Layout B, the person MUST be a remove-background cutout (no rectangular photo box). Deterministic; honor references/builder-contract.md.
stat — category module
A single hero number reveal. Asset-free (the "input" is the number). ~4–6s.
Plan (Director)
content: { value, prefix ($), suffix (% / x / K-M-B), label, ring: bool }. Envelope: bold display font, restrained palette + one accent.
Vocabulary / leans on
- Block:
apple-money-count(finance-flavoured: $ counter + green flash + money burst + SFX) when it fits; otherwise hand-author. - Rules:
hyperframes-animation/rules/{counting-dynamic-scale, stat-bars-and-fills}. - Primitives:
count_up(odometer) ·scale_pop·ring_fill(arc) ·label_stagger(after value) ·hold.
Build (reuse-first)
Reuse apple-money-count + set target / prefix / suffix / label / palette; or hand-author per the proven prototype v0-stat-motion-demo:
- count-up is timeline-driven — tween a proxy
{v:0}→targetwithonUpdatewriting the formatted number (seek-safe; never setInterval/wall-clock). font-variant-numeric: tabular-nums; decelerating ease; ~1.2–1.6s then hold the final value.- ring/arc via
stroke-dashoffset, finishing in sync with the count-up; label fades in after the number lands (value → meaning).
tweet — category module (search-driven)
Search a tweet → animate the tweet card. Grounded in a real post (RWA). ~4–8s.
Source (Step 2)
RWA search_tweets (specific query, or a given tweet URL/id) → tweet: author, handle, avatar, text, timestamp, metrics (likes/reposts). asset_needs: { kind: tweet, query|source, treatment: none }. Freeze the avatar + any embedded media.
Vocabulary / leans on
- Block: registry
x-post(animated X/Twitter post card overlay with engagement metrics) — reuse it directly. - Primitives: card slide/scale-in · text type-on / line reveal · avatar pop · metrics count-up · optional emphasis on a keyword.
Build (reuse-first)
npx hyperframes add x-post → fill author / handle / avatar / text / metrics from the resolved tweet; animate the card in, type-on the text (or line-by-line reveal), count-up the metrics. Frozen project-local avatar/media (never a remote URL). export: alpha-overlay if it's meant to sit over other footage.
webpage — category module (search-driven)
Animate a webpage / link (the "website animation" use case). Search-driven: the user gives or names a URL → fetch/capture it → animate it (scroll-through, UI reveal, cursor demo, region callouts). Distinct from captioning an existing _video file_ (that's /embedded-captions) and from a narrated video _of_ a site (that's /website-to-video); here the source is a _web page_ and the output is a short, unnarrated highlight shot.
Source (Step 2)
Fetch the page via hyperframes capture (DOM + screenshots) or a provided screenshot → frozen project-local image(s)/DOM. asset_needs: { kind: web, source: <url>, treatment: none }.
Vocabulary / leans on
- Rules:
hyperframes-animation/rules/{3d-page-scroll, demo-page-scroll-spotlight, cursor-click-ripple, coordinate-target-zoom, viewport-change}. - Primitives: scroll pan · spotlight/dim · cursor move + click-ripple · zoom-to-region · callout pin + label.
Build (reuse-first)
Place the captured page as the base layer; animate a scroll-through (translateY over the page height), spotlight key regions (dim + highlight), a cursor that moves and clicks (ripple), zoom to a coordinate, and callout pins/labels anchored to page regions. Honor builder-contract.md.
Two rules that match the news article-highlight (see ../news/module.md):
- Highlights are swept/animated ON, never pre-applied — a spotlight box wipes in (
scaleX 0→1), a marker sweeps a region, a callout pops _after_ the zoom lands; the page never starts pre-annotated. - Anchor to REAL element positions —
hyperframes capturegives the page HTML/DOM, so measure the target element (getBoundingClientRect→ stage-local) and zoom/highlight it exactly, step-by-step (the "highlight a real element" technique). Don't eyeball coords.
#!/usr/bin/env node
/* locate.mjs — find "X" in an image WITHOUT any detector API or key.
*
* You (the author model) are weak at regressing pixel coordinates but strong at
* "which numbered strip is the target in?" — so localization = RSVP grid-index
* (ACL 2025 long.715): pick strips on a numbered grid, reconstruct the box
* geometrically. Zero npm deps — node + ffmpeg (both already required by
* hyperframes). Full protocol: grounding/PROTOCOL.md.
*
* THE LOOP (you read images between steps):
* 1) node locate.mjs overlay <img> --out DIR
* → DIR/gv.png (vertical strips 1..9) + DIR/gh.png (horizontal 1..9).
* READ both, pick the strip numbers the target spans.
* 2) node locate.mjs region <img> --vids 4,5 --hids 6,7 --out DIR
* → prints coarse region + writes DIR/gc.png (region cropped+upscaled,
* finer 6×6 grid). READ it, pick the finer strips.
* 3) node locate.mjs final <img> --region x0,y0,x1,y1 --vids 3,4 --hids 3,4
* → {box, center} normalized to the FULL image.
* 4) node locate.mjs mark <img> --box x0,y0,x1,y1 --out DIR/check.png
* → VERIFY: read check.png — is the red box on the target? If off,
* redo step 2/3 with corrected strips. Never skip this.
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, copyFileSync } from "node:fs";
const N1 = 9,
PAD1 = 0.4; // stage 1: strips per axis, padding in STRIP units
const N2 = 6,
PAD2 = 0.25; // stage 2 (crop)
const GREEN = "0x3CDC5A",
BLUE = "0x3C82FF",
RED = "0xFF3232";
const ff = (args) =>
execFileSync("ffmpeg", ["-v", "error", "-y", ...args], {
stdio: ["ignore", "ignore", "inherit"],
});
function probe(img) {
const out = execFileSync("ffprobe", [
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0",
img,
])
.toString()
.trim();
const [w, h] = out.split(",").map(Number);
return { w, h };
}
function font() {
const dst = "/tmp/locate-font.ttf";
if (!existsSync(dst)) {
for (const c of [
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
]) {
if (existsSync(c)) {
copyFileSync(c, dst);
break;
}
}
}
return dst;
}
function gridFilters(w, h, n, axis, fz) {
const F = font(),
fs = [];
if (axis === "v" || axis === "both") {
for (let i = 1; i < n; i++)
fs.push(`drawbox=x=${Math.round((i * w) / n)}:y=0:w=2:h=${h}:color=${GREEN}@0.8:t=fill`);
for (let i = 0; i < n; i++)
fs.push(
`drawtext=fontfile=${F}:text='${i + 1}':x=${Math.round(((i + 0.5) * w) / n - fz * 0.3)}:y=6:fontsize=${fz}:fontcolor=black:box=1:boxcolor=white@0.95:boxborderw=5`,
);
}
if (axis === "h" || axis === "both") {
for (let j = 1; j < n; j++)
fs.push(`drawbox=x=0:y=${Math.round((j * h) / n)}:w=${w}:h=2:color=${BLUE}@0.8:t=fill`);
for (let j = 0; j < n; j++)
fs.push(
`drawtext=fontfile=${F}:text='${j + 1}':x=6:y=${Math.round(((j + 0.5) * h) / n - fz * 0.5)}:fontsize=${fz}:fontcolor=black:box=1:boxcolor=white@0.95:boxborderw=5`,
);
}
return fs;
}
const ids = (s) => String(s).split(",").filter(Boolean).map(Number);
const regionOf = (v, h, n, pad) => [
Math.max(Math.min(...v) - 1 - pad, 0) / n,
Math.max(Math.min(...h) - 1 - pad, 0) / n,
Math.min(Math.max(...v) + pad, n) / n,
Math.min(Math.max(...h) + pad, n) / n,
];
const r4 = (x) => Math.round(x * 1e4) / 1e4;
const out = (o) => console.log(JSON.stringify(o));
// --- tiny arg parsing: locate.mjs <cmd> <img?> --k v ---
const [cmd, ...rest] = process.argv.slice(2);
const pos = rest.filter((a) => !a.startsWith("--"));
const opt = {};
for (let i = 0; i < rest.length; i++)
if (rest[i].startsWith("--")) opt[rest[i].slice(2)] = rest[i + 1];
const img = pos[0];
if (cmd === "overlay") {
const dir = opt.out || ".";
mkdirSync(dir, { recursive: true });
const n = Number(opt.n || N1);
const { w, h } = probe(img);
const fz = Math.max(16, Math.round(Math.min(w, h) / 22));
ff(["-i", img, "-vf", gridFilters(w, h, n, "v", fz).join(","), `${dir}/gv.png`]);
ff(["-i", img, "-vf", gridFilters(w, h, n, "h", fz).join(","), `${dir}/gh.png`]);
out({
w,
h,
n,
vertical: `${dir}/gv.png`,
horizontal: `${dir}/gh.png`,
next: "READ gv.png + gh.png, then: locate.mjs region <img> --vids .. --hids .. --out " + dir,
});
} else if (cmd === "region") {
const dir = opt.out || ".";
mkdirSync(dir, { recursive: true });
const n = Number(opt.n || N1),
nf = Number(opt.nf || N2);
const reg = regionOf(ids(opt.vids), ids(opt.hids), n, PAD1);
const { w, h } = probe(img);
const cx = Math.floor(reg[0] * w),
cy = Math.floor(reg[1] * h);
const cw = Math.max(2, Math.floor((reg[2] - reg[0]) * w)),
ch = Math.max(2, Math.floor((reg[3] - reg[1]) * h));
const uw = Math.max(cw, 640),
uh = Math.round((ch * uw) / cw);
const fz = Math.max(15, Math.round(Math.min(uw, uh) / 16));
ff([
"-i",
img,
"-vf",
[
`crop=${cw}:${ch}:${cx}:${cy}`,
`scale=${uw}:${uh}`,
...gridFilters(uw, uh, nf, "both", fz),
].join(","),
`${dir}/gc.png`,
]);
out({
region: reg.map(r4),
crop_grid: `${dir}/gc.png`,
nf,
next: `READ gc.png, then: locate.mjs final <img> --region ${reg.map(r4).join(",")} --vids .. --hids ..`,
});
} else if (cmd === "final") {
const nf = Number(opt.nf || N2);
const reg = opt.region.split(",").map(Number);
const rl = regionOf(ids(opt.vids), ids(opt.hids), nf, PAD2);
const [x0, y0, x1, y1] = reg;
const box = [
x0 + rl[0] * (x1 - x0),
y0 + rl[1] * (y1 - y0),
x0 + rl[2] * (x1 - x0),
y0 + rl[3] * (y1 - y0),
].map(r4);
out({
box,
center: [r4((box[0] + box[2]) / 2), r4((box[1] + box[3]) / 2)],
next:
"VERIFY: locate.mjs mark <img> --box " +
box.join(",") +
" --out <dir>/check.png — then READ it",
});
} else if (cmd === "mark") {
const box = opt.box.split(",").map(Number);
const { w, h } = probe(img);
const bx = Math.round(box[0] * w),
by = Math.round(box[1] * h);
const bw = Math.max(2, Math.round((box[2] - box[0]) * w)),
bh = Math.max(2, Math.round((box[3] - box[1]) * h));
const dst = opt.out || "./check.png";
ff(["-i", img, "-vf", `drawbox=x=${bx}:y=${by}:w=${bw}:h=${bh}:color=${RED}@1:t=6`, dst]);
out({
marked: dst,
next: "READ it — red box on the target? If off, redo region/final with corrected strips.",
});
} else if (cmd === "auto") {
// OPTIONAL fast path — only if a strong detector key happens to exist. Never assume it.
const key = process.env.GEMINI_API_KEY;
if (!key) {
console.error(
"auto needs GEMINI_API_KEY; use the grid loop instead (overlay→region→final→mark)",
);
process.exit(1);
}
const target = pos[1];
const b64 = execFileSync("base64", ["-i", img]).toString().replace(/\n/g, "");
const mime = img.match(/\.png$/i) ? "image/png" : "image/jpeg";
const body = {
contents: [
{
parts: [
{ inline_data: { mime_type: mime, data: b64 } },
{
text: `Detect "${target}". Return ONLY {"box_2d":[ymin,xmin,ymax,xmax]} integers 0-1000.`,
},
],
},
],
generationConfig: { temperature: 0, response_mime_type: "application/json" },
};
const res = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key=${key}`,
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) },
);
const t = (await res.json()).candidates[0].content.parts[0].text;
let bb = JSON.parse(t.match(/\{[\s\S]*\}/)[0]).box_2d;
if (Array.isArray(bb[0])) bb = bb[0];
const s = Math.max(...bb) > 1.5 ? 1000 : 1;
const box = [bb[1] / s, bb[0] / s, bb[3] / s, bb[2] / s].map(r4);
out({ box, center: [r4((box[0] + box[2]) / 2), r4((box[1] + box[3]) / 2)] });
} else {
console.error("usage: locate.mjs overlay|region|final|mark|auto … (see header comment)");
process.exit(1);
}
locate — find "X" in an image (no detector API assumed)
The single contract every consumer (asset-fusion ring, webpage highlight, future explainer/recut overlays) uses:
locate(image, target-description) → { box: [x0,y0,x1,y1], center: [cx,cy] } # normalized 0..1Why this exists
Author models are unreliable at regressing pixel coordinates on a cluttered full image (measured: weak vision models ~16–24% center error → rings land off-target) but reliable at picking a numbered strip (~3–4% with the loop below). So: never eyeball coordinates; localize by discrete choice. (RSVP, ACL 2025.)
Routing — pick the cheapest path that's actually available
- A strong detector is available (e.g.
GEMINI_API_KEYin env) →node grounding/locate.mjs auto <img> "<target>"— one call, done. Never assume the key exists. No key → path 2. - No detector (the normal case) → YOU are the localizer; run the grid loop.
The grid loop (you read images between steps)
node grounding/locate.mjs overlay <img> --out /tmp/g
→ READ /tmp/g/gv.png (vertical strips 1-9) and gh.png (horizontal 1-9);
decide which strip numbers the target spans (list EVERY strip it touches).
node grounding/locate.mjs region <img> --vids 4,5 --hids 6,7 --out /tmp/g
→ READ /tmp/g/gc.png (the region cropped + upscaled, finer 6×6 grid);
pick the finer strips. (Pick strips again — do NOT switch to estimating
coordinates; discrete choice is the whole point, at BOTH stages.)
node grounding/locate.mjs final <img> --region <from step 2> --vids 3,4 --hids 3,4
→ the final {box, center}.
node grounding/locate.mjs mark <img> --box <final box> --out /tmp/g/check.png
→ VERIFY: READ check.png. Red box ON the target → done. Off → redo
region/final with corrected strips (you now know which direction). Never
skip this step; it converts silent misses into one cheap retry.Ambiguity — resolve BEFORE localizing
If the target description can match several instances ("a drum" when the scene has five), no geometry will save you. First make the reference unique ("the right-most drum", "the drum on the front boat"), asking the user if needed — then run the loop.
Degradation path (if locate.mjs itself is unavailable)
The idea is 5 lines — reproduce it with any image tool: draw a numbered 9×9 grid → pick the strips the target spans → crop that region (+pad ~0.4 strip) and upscale → draw a finer 6×6 grid → pick again → map back (global = region_origin + local × region_size) → draw the box and LOOK at it.
Notes
- Geometry is frozen in
locate.mjs(node + ffmpeg, zero npm deps — both already required by hyperframes). Don't re-implement it ad hoc; the measured accuracy holds for THIS implementation. - Consumers:
samples/asset-fusion/_ref-circle-highlight.htmltakesCFG.boxdirectly fromfinal/autooutput. - Measured end-to-end (same agent, same template, only the localization step differs): eyeballing 6.5% avg center error → this protocol 2.3%, no case worse.
source phase — asset sourcing (asset-first)
Runs only when shot-plan.json.asset_needs is non-empty (the form categories never reach here). Sources each needed asset → a frozen project-local path + a ledger (assets/index.md). Uses /hyperframes-media (capture / asset prep) plus, when an external asset-search skill such as media-use is installed, its resolve step. If no such search capability is available, it degrades to asset-free (see below).
Per asset_need
image / icon / logo / svg→ media-useresolve: search (asset_scout: Google Images / SerpAPI + Noun Project), generate (image model), or user-supplied (logo). Optionaltreatment: cutout (remove-bg) / recolor / vectorize.news / web / tweet→ RWA-style search (media-use's documented lineage —media-use/references/search-strategy.mdtracesresolveto the RWA subagent). Two-pole queries: atomic (1–3 words, composable) or specific (5–15 words: a news event / tweet); never the middle. A failed specific query is dropped, not broadened.
Steps
- Read
asset_needsfromshot-plan.json. - For each: analyze → search → review (use/maybe/reject — selection is the hard part; do NOT take the first/generated result) → freeze the kept asset into
assets/(rehost remote URLs). - Write
assets/index.md— agent-readable ledger:role → frozen path + provenance. - For
asset-fusion: also capture the asset's measurable geometry (so Director Part 2 can setelement_positions) + an eyedropper palette.
Degrade gracefully
If a provider / search is unavailable, mark the need unmet in context.log; the category falls back to asset-free where possible (e.g. news → typographic headline without the sourced image).
Illustrative:
(cd "$PROJECT_DIR" && node <SKILL_DIR>/phases/source/resolve.mjs --plan ./shot-plan.json --out ./assets)— or drive media-use'sresolveprocedure directly.
Builder contract — composition rules (detail behind agents/builder.md)
Root must be sized
Root #stage (data-composition-id) needs position: relative; width: <W>px; height: <H>px. Without a resolved height, flex children collapse to ~0 and content piles into the top-left — and lint/inspect won't catch it.
Layout before animation
- Identify the hero frame (the moment most elements are visible) → build THAT in static CSS first, no GSAP.
.scene-contentfills the scene with padding, not offsets:
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
padding: 120px 160px;
gap: 24px;
box-sizing: border-box;Never position:absolute; top:Npx on a content container (it overflows). Reserve absolute for decoratives. Keep ≥80px padding (title-safe margin).
- Entrances:
gsap.from()FROM offscreen/invisible TO the CSS position (in sub-comps usefromTo()). The CSS position is ground truth; the tween is the journey to it. - Exits: only the final scene animates elements out; between scenes the transition IS the exit.
Timeline / clip contract
- ONE
gsap.timeline({paused:true})onwindow.__timelines["<id>"];tl.seek(0); nevertl.play(). - Timed elements:
class="clip"+data-start/data-duration/data-track-index+ a stableid. Timeline-driven groups inside one full-duration clip don't each need timing attrs. - Deterministic only — no
Date.now()/Math.random()/ network. Count-ups tween a proxy object viaonUpdate(seek-safe), never a wall-clock counter.
Correctness
- Seek-safe reveal of delayed elements:
gsap.set(el, {autoAlpha:0})once, then reveal withgsap.to(el, {autoAlpha:1})at the entrance (paired with a motion-onlyfrom()). Do NOT gate viaset(opacity:1) + from(opacity:0)— under a paused/seeked render the element stays invisible _forever_ (browser-play hides this; seek-capture exposes it). _(Eval finding.)_ - Count-ups tween a proxy via
onUpdate; they only render when the host advances the timeline with events enabled (tl.time()/ non-suppressed seek). A bareseek(t, true)freezes them at 0 — the HF render host must seek with events on. _(Eval finding.)_ - Clamp at tween bounds; don't let a spring overshoot past a held value.
- Allowed eases:
power1–4,back,bounce,circ,elastic,expo,sine(.in/.out/.inOut). - One motif per scene. Run
hyperframes inspect; mark intentional overflowdata-layout-allow-overflow="true". - Palette discipline: define all colors in one
paletteobject / CSS custom properties — no inline hex scattered through the markup (forasset-fusion, eyedropper the palette from the asset).
text module · motion vocabulary (primitive → GSAP)
Named primitives the Director references in motion strings and the Builder implements. code_hints are framework-neutral physics; the GSAP recipe is the HF implementation. Prefer an HF registry component (bottom) when one fits — don't reinvent.
Entry
| primitive | GSAP recipe (into CSS end-state) | suits |
|---|---|---|
slide_bottom/top/left/right | from({ y:±150 / x:±200, opacity:0, ease:"power4.out" }) | calm, build, professional |
scale_grow | from({ scale:0, opacity:0, duration:.6, ease:"power2.out" }) | calm, gentle |
scale_punch | from({ scale:.6, opacity:0, ease:"back.out(2.2)" }) | impact, energetic |
fade_in | from({ opacity:0, duration:.4 }) | subtle |
fade_blur | from({ opacity:0, filter:"blur(14px)" }) | cinematic, dreamy |
typewriter | reveal via clip/SplitText width step | technical, narrative |
word_reveal | per-word from({opacity:0,y:..}, stagger:.1) | storytelling |
wave | per-letter from({y:..}, stagger:{each:.04}) | flowing, musical |
bounce_in | from({y:-120}, ease:"bounce.out") | playful |
slam | from({ y:-300, ease:"power4.out" }) + shake on land | impact, heavy |
Emphasis (in place, often on a beat)
| primitive | GSAP recipe | suits |
|---|---|---|
scale_pulse | to({ scale:1.12, yoyo:true, repeat:1, ease:"sine.inOut" }) at beat | rhythmic, peak |
shake | to({ keyframes:[{x:-9},{x:9},{x:0}], ease:"none" }) | urgent, intense |
glow | to({ textShadow:"0 0 46px <ink/accent>", yoyo:true, repeat:1 }) | important, magical |
color_shift | to({ color:"<accent>" }) (or accent on the word in CSS) | dynamic |
Exit
| primitive | GSAP recipe | suits |
|---|---|---|
fade_out | to({ opacity:0, duration:.4, ease:"power2.in" }) | ending |
slide_out | to({ y/x: off, opacity:0, ease:"power2.in" }) | transition |
scale_out | to({ scale:1.06, opacity:0, ease:"power2.in" }) | transition |
Accent graphics (not text)
underline_sweep fromTo({scaleX:0},{scaleX:1}, transformOrigin:"left center") · bar_wipe · hold_breath to({scale:1.015, ease:"sine.inOut"}).
Prefer HF registry components when they fit
caption-kinetic-slam · caption-editorial-emphasis · caption-neon-glow · caption-glitch-rgb · caption-particle-burst · caption-weight-shift · caption-matrix-decode · caption-pill-karaoke · shimmer-sweep. These are pre-built, in-ecosystem, and already render-tested — the Builder should reach for them before hand-rolling an equivalent.
shot-plan IR
The single contract between Director and Builder. One file: PROJECT_DIR/shot-plan.json.
{
// ── envelope (every category) ──
"category": "kinetic-type | stat | charts | logo-reveal | lower-thirds | webpage | news | tweet | asset-fusion",
"duration_s": 6,
"fps": 30,
"canvas": { "w": 1080, "h": 1920, "aspect": "9:16" },
"style": "free-form visual direction (mood / energy / reference)",
"palette": ["#…"], // or "derive-from-asset"
"font": "<HF embed-list font>",
"beats": [12, 37], // optional accent frames/seconds
"export": "mp4", // or "alpha-overlay" (transparent webm/mov)
// ── sourcing seam (Director Part 1) — [] means skip the source phase ──
"asset_needs": [
{
"role": "hero",
"kind": "image|icon|logo|svg|news|web|tweet",
"query": "…",
"source": "…",
"treatment": "cutout|recolor|vectorize|none",
},
],
// ── build directive (Director Part 2, reuse-first) ──
"block": "<catalog block id, e.g. data-chart | caption-kinetic-slam>", // optional
"customize": {
/* what to change on the block: data, text, palette, positions */
},
// ── category-specific content ──
"content": {
/* shape varies by category, below */
},
}Per-category content shapes:
kinetic-type→scenes[]{ id, start, end, text, emphasis_words[], emotion, motion, beats[] }stat→{ value, prefix, suffix, label, ring: bool }charts→{ type: bar|line|pie|race|pct, data[], labels[], headline, axes: bool }logo-reveal→{ logo: <asset path>, tagline, url }lower-thirds→{ name, role, position, brand_colors[] }webpage→{ url, capture, highlights: [ { selector|region, label } ] }(step-highlight a real captured page)news→{ outlet, headline, body, keyword, layout: A|B, logo?, date?, subject? }(article-highlight: lay text out readable — no zoom — then sweep a marker band over the keyword in place. Layout A = centered-emphasis 9:16 text-only; B = full article 16:9 withlogo+date+subject(a person photo →remove-backgroundcutout))tweet→{ author, handle, avatar, text, metrics }asset-fusion→{ data_type, asset: <path>, affordance, element_positions: {center, extent, safe[], avoid[]}, derived_palette[], connectors[] }
Invariants: scenes (if present) partition [0, duration_s] with no gaps/overlaps · empty asset_needs ⇒ Step 2 (source) is skipped · a named block ⇒ the Builder reuses + customizes it rather than hand-authoring.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>asset-fusion · circle-highlight TEMPLATE (drop-in)</title>
<!-- ============================================================
DROP-IN circle-highlight (editorial amber annotation style). To reuse:
1) set CFG below (asset path, EVEN stage dims, BOX from the locate protocol, LABEL)
2) render with even width/height (ODD width => ffmpeg encode fails!)
BOX comes from grounding/PROTOCOL.md (locate.mjs loop) — NEVER eyeball coords.
Everything (wash pool, double ring, connector, callout, brackets) is
computed from BOX. Set MODE='full' (ring+label+brackets) or 'circle'
(ring only). Gotchas are flagged inline.
============================================================ -->
<style>
:root {
--ink: #0a0c10;
--amber: #f2c078;
--paper: #f5efe6;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
overflow: hidden;
background: var(--ink);
}
#stage {
position: relative;
overflow: hidden;
background: #fff;
font-family:
"Inter",
-apple-system,
system-ui,
sans-serif;
}
.scene {
position: absolute;
inset: 0;
}
#shot {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
z-index: 0;
}
#wash {
position: absolute;
inset: 0;
z-index: 1;
}
#ann {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
overflow: visible;
}
#ring-a,
#ring-b,
#conn {
stroke: var(--amber);
fill: none;
stroke-linecap: round;
}
#ring-a {
stroke-width: 8;
filter: drop-shadow(0 0 12px rgba(242, 192, 120, 0.5));
}
#ring-b {
stroke-width: 5;
opacity: 0.85;
}
#conn {
stroke-width: 5;
}
#callout {
position: absolute;
z-index: 4;
display: inline-flex;
align-items: center;
gap: 14px;
padding: 15px 28px;
background: var(--ink);
border: 2px solid var(--amber);
border-radius: 999px;
box-shadow: 0 14px 40px rgba(8, 9, 12, 0.6);
transform-origin: left center;
}
#callout .dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--amber);
}
#callout .label {
font-size: 33px;
font-weight: 700;
letter-spacing: 0.14em;
color: var(--paper);
text-transform: uppercase;
white-space: nowrap;
}
.tick {
position: absolute;
z-index: 5;
width: 56px;
height: 56px;
border: 3px solid var(--paper);
opacity: 0.55;
}
.tick.tl {
top: 44px;
left: 44px;
border-right: none;
border-bottom: none;
}
.tick.tr {
top: 44px;
right: 44px;
border-left: none;
border-bottom: none;
}
.tick.bl {
bottom: 44px;
left: 44px;
border-right: none;
border-top: none;
}
.tick.br {
bottom: 44px;
right: 44px;
border-left: none;
border-top: none;
}
#scan {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
mix-blend-mode: overlay;
background: repeating-linear-gradient(
to bottom,
rgba(245, 239, 230, 0.05) 0 1px,
transparent 1px 4px
);
}
</style>
</head>
<body>
<!-- ⚠ data-width/height + the #stage width/height MUST be set STATICALLY here
(the renderer's StaticGuard reads them at compile time, BEFORE JS runs — JS
setAttribute is too late and the render falls back to portrait 1080×1920).
Keep these EQUAL to CFG.W/CFG.H below, and EVEN. -->
<div
id="stage"
style="width: 1998px; height: 1368px"
data-composition-id="circle-highlight"
data-start="0"
data-duration="6"
data-fps="30"
data-width="1998"
data-height="1368"
>
<div class="scene clip" data-start="0" data-duration="6" data-track-index="0">
<img id="shot" alt="asset" />
<div id="wash"></div>
<svg id="ann">
<ellipse id="ring-a" />
<ellipse id="ring-b" />
<path id="conn" />
</svg>
<div id="callout"><span class="dot"></span><span class="label"></span></div>
<div id="scan"></div>
<span class="tick tl"></span><span class="tick tr"></span><span class="tick bl"></span
><span class="tick br"></span>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
// ====== CONFIG — set these (BOX from the locate protocol) ======
const CFG = {
asset: "assets/shot.png",
W: 1998,
H: 1368, // EVEN dims (odd width => encode fails)
box: [0.244, 0.277, 0.607, 0.425], // [x0,y0,x1,y1] normalized, from locate.mjs
label: "HyperFrames",
mode: "full", // 'full' = ring+connector+label+brackets+scan ; 'circle' = ring only
};
// =====================================================
const S = document.getElementById("stage"); // dims are STATIC in the HTML above (must equal CFG.W/H)
document.getElementById("shot").src = CFG.asset;
const ann = document.getElementById("ann");
ann.setAttribute("viewBox", `0 0 ${CFG.W} ${CFG.H}`);
ann.setAttribute("width", CFG.W);
ann.setAttribute("height", CFG.H);
// geometry from BOX
const [x0, y0, x1, y1] = CFG.box;
const cx = ((x0 + x1) / 2) * CFG.W,
cy = ((y0 + y1) / 2) * CFG.H;
const bw = (x1 - x0) * CFG.W,
bh = (y1 - y0) * CFG.H;
const rxA = bw / 2 + 40,
ryA = bh / 2 + 42;
// wash: a lit radial pool centered on the box, dark elsewhere
const px = (((x0 + x1) / 2) * 100).toFixed(1),
py = (((y0 + y1) / 2) * 100).toFixed(1);
const ex = (((x1 - x0) / 2) * 1.15 * 100).toFixed(1),
ey = (((y1 - y0) / 2) * 1.4 * 100).toFixed(1);
document.getElementById("wash").style.background =
`radial-gradient(ellipse ${ex}% ${ey}% at ${px}% ${py}%, rgba(8,9,12,0) 0%, rgba(8,9,12,.1) 42%, rgba(8,9,12,.66) 70%, rgba(8,9,12,.9) 100%)`;
const ra = document.getElementById("ring-a"),
rb = document.getElementById("ring-b");
ra.setAttribute("cx", cx);
ra.setAttribute("cy", cy);
ra.setAttribute("rx", rxA);
ra.setAttribute("ry", ryA);
ra.setAttribute("transform", `rotate(-4 ${cx} ${cy})`);
rb.setAttribute("cx", cx);
rb.setAttribute("cy", cy);
rb.setAttribute("rx", rxA - 20);
rb.setAttribute("ry", ryA - 16);
rb.setAttribute("transform", `rotate(3 ${cx} ${cy})`);
// callout: above-right of the box (flip left if box is on the right edge)
const right = cx < CFG.W * 0.62;
const co = document.getElementById("callout");
co.querySelector(".label").textContent = CFG.label;
const coX = right ? Math.min(cx + rxA + 10, CFG.W - 360) : Math.max(cx - rxA - 360, 30);
const coY = Math.max(cy - ryA - 60, 40);
co.style.left = coX + "px";
co.style.top = coY + "px";
const conn = document.getElementById("conn");
const ex0 = cx + (right ? rxA * 0.75 : -rxA * 0.75),
ey0 = cy - ryA * 0.7;
conn.setAttribute(
"d",
`M ${ex0} ${ey0} C ${(ex0 + coX) / 2} ${ey0 - 50}, ${coX - 20} ${coY + 70}, ${coX + 2} ${coY + 26}`,
);
// ====== seek-safe timeline ======
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// GOTCHA: getTotalLength can read 0 before layout -> dash fails -> a solid
// line shows at t=0. Fall back to a constant AND gate with autoAlpha:0.
const lenA = ra.getTotalLength() || 1900,
lenB = rb.getTotalLength() || 1750,
lenC = conn.getTotalLength() || 220;
const full = CFG.mode === "full";
gsap.set("#wash", { autoAlpha: 0 });
gsap.set(ra, { strokeDasharray: lenA, strokeDashoffset: lenA, autoAlpha: 0 });
gsap.set(rb, { strokeDasharray: lenB, strokeDashoffset: lenB, autoAlpha: 0 });
gsap.set(conn, { strokeDasharray: lenC, strokeDashoffset: lenC, autoAlpha: 0 });
gsap.set("#callout", { autoAlpha: 0, scale: 0.6 });
gsap.set("#scan", { autoAlpha: 0 });
gsap.set(".tick", { autoAlpha: 0 });
if (!full) {
document.getElementById("conn").remove();
document.getElementById("callout").remove();
document.getElementById("scan").remove();
document.querySelectorAll(".tick").forEach((t) => t.remove());
}
tl.to("#wash", { autoAlpha: 1, duration: 0.9, ease: "power2.out" }, 0.6);
if (full) {
tl.to("#scan", { autoAlpha: 1, duration: 0.8 }, 0.7);
tl.to(".tick", { autoAlpha: 0.55, duration: 0.5, stagger: 0.06 }, 0.8);
}
tl.to(ra, { autoAlpha: 1, duration: 0.08 }, 1.3);
tl.to(ra, { strokeDashoffset: 0, duration: 0.6, ease: "power2.inOut" }, 1.3);
tl.to(rb, { autoAlpha: 1, duration: 0.08 }, 1.68);
tl.to(rb, { strokeDashoffset: 0, duration: 0.5, ease: "power2.inOut" }, 1.68);
if (full) {
tl.to(conn, { autoAlpha: 1, duration: 0.05 }, 2.55);
tl.to(conn, { strokeDashoffset: 0, duration: 0.35, ease: "power3.out" }, 2.6);
tl.to("#callout", { autoAlpha: 1, scale: 1, duration: 0.45, ease: "back.out(2.4)" }, 2.95);
}
window.__timelines["circle-highlight"] = tl;
tl.seek(0);
if (!window.__HYPERFRAMES_HOST__) {
const fit = () => {
const s = Math.min(innerWidth / CFG.W, innerHeight / CFG.H) * 0.96;
S.style.transform = `scale(${s})`;
S.style.transformOrigin = "center center";
S.style.position = "absolute";
S.style.left = "50%";
S.style.top = "50%";
S.style.marginLeft = -(CFG.W / 2) + "px";
S.style.marginTop = -(CFG.H / 2) + "px";
};
addEventListener("resize", fit);
fit();
tl.eventCallback("onComplete", () => setTimeout(() => tl.restart(), 1000));
tl.play();
}
</script>
</body>
</html>
Which skills are related to motion-graphics?
Motion Graphics skill FAQ
Who is the Motion Graphics skill for?
Creating social media motion graphics and animated design elements for marketing. Skip if: Long brand reels, narrated explainers, website-to-video walkthroughs, or product launch promos requiring general-video.
When should I use motion-graphics?
Creating short animated design elements or social media graphics.
What types of motion graphics can I create?
Kinetic typography, animated statistics, logo stings, lower-thirds, and animated tweets - anything short-form and unnarrated under 10 seconds.
Can I create transparent overlays?
Yes - output can be MP4 video or transparent PNG/WebP sequences for compositing over other content.
Is Motion Graphics safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.
Add a motion-graphics badge
Show developers this skill is listed on Skillselion. Paste this into your README.