feat(skills)!: publish seven knowledge skills aligned with Studio and retire the workflow skills (#5651)

* refactor(skills): fold six domain skills into hyperframes and media-use guides

hyperframes-core, -animation, -keyframes, -creative and -studio move to
skills/hyperframes/{core,animation,keyframes,creative,studio}/, and
hyperframes-audio moves to skills/media-use/mix/. Each moved SKILL.md becomes
a frontmatter-free GUIDE.md so installers that scan recursively for SKILL.md
no longer see them as skills; the published list goes from 21 to 15.

Relative links, code paths and skill-name mentions are rewritten to the new
locations, the catalog surfaces, marketplace core-skills list, fallback core
set, module-copy targets and skills-manifest.json follow, and
`hyperframes skills update` now removes the retired skills (RETIRED_SKILLS)
when they are still installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): slim the core entries and give media-use a HeyGen and a free path

The four entries every run opens are rewritten to what an agent cannot infer:
hyperframes 21.8 KB to 7.7 KB, hyperframes-cli 24.8 KB to 6.9 KB,
hyperframes-registry 9.9 KB to 4.3 KB, media-use 9.7 KB to 4.9 KB. Detail moved
whole into references (routing close calls and creator edits, CLI pin upgrades,
catalog search tiers, CLI conventions and feedback reports).

- hyperframes: state table, routing, the render contract, a terminal work loop
  (check, snapshot, preview before render), the desktop-app hand-off, and a
  table of the folded guides.
- media-use: one table of HeyGen versus free local tools per need; HeyGen
  sign-in, credits and the CLI's video-agent, avatar, clone, lipsync, translate
  and clipping commands; every free path works with no account.
- scripts/check-core-skills.mjs holds entries and descriptions to budget and
  checks every taught `npx hyperframes` command and path; runs in lint.
- Core entries are exempt from the formatter: its table padding cost ~1.5 KB
  per entry on every run.
- Content tests follow the text to its new home.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): plugin runs skip skills update; media-use explains HeyGen sign-in and TTS setup

The smoke eval showed a presenter request never reaching media-use and the
free voiceover failing on a missing kokoro-onnx; plugin runs also ran the
skills update the plugin rules forbid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(skills): drop a committed Python bytecode file and ignore bytecode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): align the entry with the Studio skills and keep the workflows' measured facts

The entry drops routing and the intent interview and carries the contract, the
editable-project rules and the work loop. Studio's format, runtimes, motion,
look and captions references come over for terminal use, frame presets move to
assets/, media-use gains Studio's mixing and local-command references, and
film.md and overlays.md keep what the workflow skills measured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills)!: retire the workflow skills and folded guides; publish seven knowledge skills

The skill pack now publishes exactly seven skills - hyperframes,
hyperframes-cli, hyperframes-registry, media-use, figma, slideshow and
remotion-to-hyperframes - carrying knowledge, not process. The eight
workflow skills, the folded domain guides and the routing/interview
references are deleted; their measured facts already live in the
hyperframes references (format, runtimes, motion, look, captions, film,
overlays) and media-use's mixing reference.

- bake-basemap.mjs and locate.mjs move to skills/hyperframes/scripts/
  (bake-basemap gains --help); extract-audio-data.py moves to
  media-use/audio/scripts/ for the caption motion reference.
- skills update: the eight workflows join RETIRED_SKILLS so existing
  installs prune them (by lock attribution only, since their names are
  generic); naming a retired skill reports where its knowledge went and
  succeeds instead of failing as unknown.
- Capture prompts point at the hyperframes skill's references/film.md;
  the live-map warning names the new bake-basemap path.
- Content tests that pinned knowledge are retargeted to the kept
  references; tests of deleted workflows, scripts and process go.
- Catalog surfaces, templates and docs describe the seven skills.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): resolve the local.md captions link and allow capture's output files in citations

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): regenerate the skills manifest from tracked files only

The committed media-use hash included an ignored __pycache__ bytecode file present
in the local checkout, so CI's clean checkout hashed differently.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(docs): restore generated catalog pages and docs navigation to main

Catalog PRs commit item sources only; CI regenerates the pages from the edited
registry sources. docs/docs.json is catalog navigation, so its Workflows group
rename is left for a non-catalog PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* build(cli): raise the CLI size budget for the skill references it bundles

The CLI bundles skills/hyperframes, which now carries the folded domain guides and
frame presets (155 KB to 1.08 MB in git). Budgets are the CI-measured size plus 5%:
packed 7526634, unpacked 22593213.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: keep repo-sweep guards meaningful after the workflow skills left

The last shell ffprobe caller and most root index.html examples lived in retired
skills. The shell detector is now pinned on a literal command line, and the
structure-rule sweep needs more than 5 roots (11 remain) rather than 30.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): slim figma, slideshow and remotion to the Studio shape; restore the storyboard board

slideshow 33.5 KB to 4.9, figma 18.2 to 6.2, remotion-to-hyperframes 11.0 to
4.9, each keeping the facts only it holds (the figma motion helpers, verify gate,
storyboard decoding and usage beacons; present and hand-off; the Remotion lint,
SSIM check and API map). The board page format from the storyboard recipe comes
back as hyperframes/references/storyboard.md: the desktop app's Storyboard tab
reads boards/storyboard.html by its frame, title and time classes. The entry and
the init template now say to snapshot the moments an edit changed before
replying, which the terminal eval found skipped in 22 of 38 runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): manifest without local bytecode; Studio's edit prompt names kept files

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): render when the person asks for the file or for no stops, not only after approval

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): keep the voiceover carve in media-use; presets stage their own fonts

Review follow-ups on #5651:
- carve.mjs and its tests move to skills/media-use/audio/scripts; the audio
  docs, mixing.md and two Studio comments point there.
- code-editorial's FRAME.md says to copy its bundled WOFF2s and add the
  @font-face blocks, instead of promising the retired build-frame.mjs.
- media-use states the host rule before the HeyGen sign-in steps.
- storyboard-format marks transition_in as a note the agent builds itself.
- Two stale citations (creator-editing-recipes.md, sub-compositions.md).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(skills): regenerate the manifest without local bytecode

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): the CLI render step follows step 4's rule, not approval alone

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(ci): run the core-skills check from lint again after the main merge

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): films that tell a story start from three storylines; maps use real imagery

Opus 5.5 arm D read motion.md and applied it, but its launch film was a
feature list (pain, three feature cards, proof, logo): film.md left story
to the model. And the map case never opened film.md, so it drew Natural
Earth outlines instead of baking satellite tiles.

- references/story.md: the generic half of Studio's moment-film story and
  craft (one thing, three storylines with world and device, the hook,
  beats, worlds, copy, brand, tells of a generated film). The person picks
  a storyline, or the agent takes its recommendation when asked for the
  file or for no stops.
- The entry's work loop names the storyline step for films over ~15 s; the
  references table routes stories to story.md and maps to film.md.
- film.md points at story.md and gives maps and real places a section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): story.md follows the entry: no voiceover unless asked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): bake-basemap starts from a globe and stops waiting out its idle timeout

The Opus 5.5 map run spent 13 of its 38 minutes on parallel bakes that
stalled and 15 more recording 300 frames, after rewriting the script because
it could only start on a flat map.

- PROJECTION=globe with a seeded starfield and atmosphere, and FROM/PAN to
  turn the globe to CENTER before the dive.
- Each frame polls for loaded tiles instead of waiting for an "idle" event
  that had often already fired: 30 frames went from 198-219 s to 10-13 s
  locally with byte-identical frames.
- Progress prints seconds a frame and minutes left; film.md shows the globe
  call and says to run one bake at a time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): render in the foreground; wait on a detached render's log, never pgrep -f

Both Opus 5.5 explainer runs started a detached render, then waited with
`until ! pgrep -f "hyperframes render"`, which matches its own loop: each
render took 88-117 s and the wait sat for the tool's full 10 minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): route each kind of film; storylines carry look and sound; add review and sound

The entry routes by request (launch, explainer, music, data, vertical, 3D,
character) to a self-contained file under references/types/, and a film
over ~15 s now gets music and sounds on its visible actions unless the
person asked for silence. Each storyline names its look and its sound, so
picking one picks both; STORYBOARD.md frames carry `reads` and `sound`.

review.md is the whole-film pass: the check warnings, the review sheets,
seven questions to look for, P0/P1/P2 notes and REVIEW.md. motion.md gains
eases by role, six title entrances, a reading-time rule and nine measured
transitions; look.md a design plan before the first scene, with presets
kept for decks and named picks. story.md adds the second default that
generated design falls into, with what to do instead.

The blank template and format.md no longer teach the fade-and-slide-up
entrance on a dark canvas.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(cli): check warns on unread lines, dead stretches, small type, empty frame 0 and silence

A coding agent cannot watch its film, so check now measures what a viewer
notices over time. A new `craft` section holds warnings only (exit 0
unless --strict), sampled on the dense 8 fps grid the overlap pass
already seeks, skipped for --range checks:

- line_unread: a reading-size line (>= 30 px tall at 1080) on screen
  under 0.4 s for 1-2 words, else under 3/4 of 0.5 s + 0.3 s a word;
  the film's last line needs 2 s. Short lines are re-measured between
  samples to ~0.01 s before judging. Captions-track and
  data-layout-ignore text are exempt.
- dead_stretch: more than 3 s where the keepsMoving liveness signature
  does not change, outside the final 3 s and any data-hold element;
  quiet when sweep_static fires or a whole-composition keepsMoving
  assertion owns the limit.
- text_below_floor: text under 2.6% of the short side (28 px on 1080)
  held 0.5 s, one finding per file listing every element.
- first_frame_empty: frame 0 paints only the background.
- film_silent: over 15 s with no unmuted audio or audible video.

Each message names the fix and the skill reference to read. keepsMoving
now also leaves the film's last 3 s (a still end card) out of its
static runs, so the two agree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(cli): snapshot --review captures a beat sheet, seam strips and a phone sheet

snapshot --review picks the frames that show a film over time instead of
evenly spaced ones, in the same single browser session:

- review-beats.jpg: one settled frame per scene at its start + 0.3 s,
  plus one per timed data-beat="name" element, labelled time and id.
  Scenes are the composition's top-level timed clips and sub-composition
  hosts, read like `timeline` reads rows (captions and audio excluded)
  and timed by the runtime's start resolver over the live page.
- review-seam-NN.jpg: frames at -0.2, -0.1, 0, +0.1, +0.2 s around
  every cut (each scene start, and any scene end no scene starts at).
- review-phone.jpg: the beat frames at 360 px wide.

--composition scopes the review to one sub-composition; with fewer than
two scenes the beats spread evenly and a note says so. snapshot also
gains --json: one envelope with every frame's path and time and, under
--review, the scenes, beats and seams behind the sheets; progress moves
to stderr.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): craft warnings stay out of --strict; a hold gets a real next action, not a caret

Craft findings (unread lines, dead stretches, small type, an empty first
frame, silence) are judgment calls a film may break on purpose, so
--strict keeps gating defects only. The check reference no longer
suggests a blinking caret to keep a still frame moving.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): review spreads beats when every scene starts together; map credits skip the type floor

A map film whose basemap and overlay both start at 0 s got one beat
frame; the even spread now keys on distinct scene starts. film.md tells
agents to mark the imagery credit data-layout-ignore and back it, since
the new type floor flagged it on the first real run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): review frames each scene at its middle too, not only 0.3 s in

On the first real explainer, 0.3 s into a scene showed a bare colour
field before its title entered. Each scene longer than about a second
and a half now adds a frame at its middle, so the sheet shows what the
scene builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(skills): a film is built from a numeric SPEC.md, with defaults banned by name and a look test

Studied in full: flxrnc's Motion Lab (vague vs directed prompt), 0xMovez's
course, EianLu's riso skill, agentchengfeng's style samples and the two
Opus 5.5 guides. Their common point: adjectives are where the default look
lives, so the agent now writes SPEC.md before any scene (GOAL, OUTPUT,
TIME, LOOK, MOTION, SHOTS, BANNED, QA), with a number for every adjective.

spec.md bans the defaults by name, unless the brand or reference uses
them (a dark void with small things floating, glow and particles, the
cream-serif-mono-label look, fade-and-slide-up, corner brackets), and
grows that list after the first render. Each storyline gets a 3 s test of
its hero shot before the pick; a look is written as the rules of one
medium. Composition numbers: the hero fills 40% of the frame, one hero
move per shot, the shot's information in within 2.5 s then a held poster.

motion.md adds springs by damping ratio, shorter exits, overlap, stagger
and the beat grid; review.md reviews as a harsh director and grows BANNED;
vertical.md takes the riso skill's measured safe band; explainer.md the
shapes of information.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): story before look; every object reads as itself; posters only for info cards

The first spec-driven C02 swapped the dark default for a flat Swiss
poster, and the story went with it: two black bars nobody read as mirrors,
slogans instead of cause and effect, a storyline picked for its picture.

The spec now serves the story: a storyline is picked for the story a
stranger would follow, the look test asks first whether a stranger
understands what they see, and SPEC.md gains an OBJECTS line giving each
thing the material and details that make it read without a label. The
2.5 s held-poster rule is limited to information cards; in a story film
every shot causes the next. Explainers start from a person or situation
the viewer knows. Review names every object as a stranger would and
treats one that needs explaining as a P0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(skills): storylines are three films in words, each with a device and a form

The three storylines are pitched as text and the person picks from them;
nothing is built for a storyline before the pick, so the 3 s hero-shot
look test is gone. Ideas pair a device with a form, five or more first,
keeping the three that differ most, and the one stop states the rest in
an assumption line that names every paid item beside its free option.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci: list main's new embedded-captions hf-cli test among the retired workflow files

The merge with main removed it with the rest of the retired skill, and the
deletion guard rejects any removal it has not been told about.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(core): keep the audio preset comment within core's size budget

The comment's new citation to media-use's mixing reference made the published
package 14 bytes larger than its budget, which sits at main's exact size.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): craft keeps its own 8 fps grid; comments back within the checks

Review on #5651 found that the merge moved craft onto the overlap pass's
times, which main caps at 180, so a brief line in a long film fell between
samples and line_unread missed it. Craft now builds its own grid at 8 fps up to
600 samples, as before the merge, and a 60 s film with a line shown for 0.2 s
pins it.

The Comments check passes again: REVIEW.md and STORYLINES.md join the files a
skill writes into a project, the comment lines this branch added come back
under each file's share at the merge-base, and the audio preset comment drops
a word the check reads as history.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
WaterrrForever
2026-10-21 19:19:31 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 207c8a4daf
commit 688229314f
916 changed files with 5824 additions and 97670 deletions
+14 -17
View File
@@ -1,13 +1,13 @@
---
name: captions-overlay
description: Overlay doctrine for the embedded-captions workflow — the caption MODEL (drop / rail / embed) and the rule that captions are an OVERLAY composited on top of the film, never a reserved bottom band you shift content up to avoid. Load when adding captions/subtitles to a talking-head or launch video, when deciding whether a phrase should be dropped, ride the verbatim rail, or be promoted to a scarce embedded climax, when laying out a composition that will carry captions (do NOT reserve a keep-out band), or when centering a composition on the true frame center under captions. Quotes the rail+embed model from embedded-captions and constraint #13 (captions overlay, keep-out band retired) from the product-launch-video scene agent. Applies ON TOP of embedded-captions.
description: Overlay doctrine for captions in HyperFrames videos — the caption MODEL (drop / rail / embed) and the rule that captions are an OVERLAY composited on top of the film, never a reserved bottom band you shift content up to avoid. Load when adding captions/subtitles to a talking-head or launch video, when deciding whether a phrase should be dropped, ride the verbatim rail, or be promoted to a scarce embedded climax, when laying out a composition that will carry captions (do NOT reserve a keep-out band), or when centering a composition on the true frame center under captions. Applies ON TOP of the `/hyperframes` skill's references/captions.md.
metadata:
internal: true
---
# Captions Overlay Doctrine
> **Overlay doctrine — supplements the upstream `embedded-captions` skill. Applies ON TOP of it; do not expect it folded into the upstream skill.**
> **Overlay doctrine — supplements the upstream `/hyperframes` skill's `references/captions.md`. Applies ON TOP of it; do not expect it folded into the upstream skill.**
Two ideas combine here. First, the **caption model** — every spoken phrase is `drop`,
`rail`, or `embed`, and embed is the scarce earned peak, not the default. Second, the
@@ -19,7 +19,7 @@ frame and centers on the true vertical center.
## The caption model — drop / rail / embed
Every spoken phrase is one of three things (verbatim from `embedded-captions`):
Every spoken phrase is one of three things:
| | What | How it's shown |
| --------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -31,15 +31,13 @@ Every spoken phrase is one of three things (verbatim from `embedded-captions`):
never two adjacent/co-visible, spaced ≥ a beat apart. A short clip → usually one embed;
a long explainer → ~one per section. Embedding every word is the common mistake.
This is the **Standard** mode shape (rail = the verbatim lower-third; embed = the climax
composited behind the subject). **Cinematic** mode drops the rail and makes everything
embed-style — use it only for pure-cinematic asks, never for explainer / voiceover where
the words must read.
This is the default shape (rail = the verbatim lower-third; embed = the climax
composited behind the subject). A pure-cinematic treatment drops the rail and makes
everything embed-style — use it only for pure-cinematic asks, never for explainer /
voiceover where the words must read.
### Rail-first, embed-scarce (the load-bearing rules)
Quoted from the `embedded-captions` non-negotiables:
- **Rail-first for talking-head / explainer.** Don't embed the whole transcript — most
text is the rail; embed only peaks. Embedding everything is the default mistake.
- **Embed is scarce + spaced.** ≤1 embed per sentence/beat, never two adjacent or
@@ -48,11 +46,10 @@ Quoted from the `embedded-captions` non-negotiables:
## The overlay law — captions are NOT a reserved band
In a generated launch composition, when captions are enabled, finalize composites a
**small, minimal word-by-word caption line** as an overlay layer ON TOP of the whole
film (a single text line, bottom-centered, roughly the bottom ~5-8% of canvas height).
It is an overlay, not a reserved zone (verbatim from constraint #13 of the
product-launch-video scene agent):
In a composed video with captions enabled, the caption line is a **small, minimal
word-by-word line** composited as an overlay layer ON TOP of the whole film (a single
text line, bottom-centered, roughly the bottom ~5-8% of canvas height). It is an
overlay, not a reserved zone:
- **Center the composition on the TRUE vertical center — y = H / 2** (landscape 540,
portrait 960). Do not shift content up to "make room" for captions; a composition
@@ -64,7 +61,7 @@ product-launch-video scene agent):
line sits — the overlay would fight it. Large imagery / cards / ambient content under
the captions is fine; the caption skin is designed to read over content.
- There is no machine keep-out gate (the old `captions.mjs keepout` check is retired).
Finalize snapshot QA judges caption-over-content legibility visually.
Snapshot review judges caption-over-content legibility visually.
**When captions are disabled:** identical positioning freedom — the overlay simply
doesn't exist.
@@ -74,8 +71,8 @@ doesn't exist.
The model says the rail rides **in front** and an embed is a rare word composited
**behind the subject** — both are layers added to footage that ships untouched. The
overlay law says the caption line is a layer composited **on top** of the whole film,
not a band carved out of the layout. So in both the captioning pipeline and the
launch-video pipeline, captions are an overlay you add, not a zone you reserve:
not a band carved out of the layout. So whether captions go over footage or over a
composed film, they are an overlay you add, not a zone you reserve:
- Keep the full frame; center on true center; let content run to the edges.
- Make the rail (or the small overlay caption line) carry the verbatim words.
+2 -8
View File
@@ -9,26 +9,20 @@
{
"name": "hyperframes",
"source": "./",
"description": "Recommended: all published HyperFrames skills in one versioned bundle. Create and render videos from HTML; load workflows on demand without installing newer standalone skills.",
"description": "Recommended: all published HyperFrames skills in one versioned bundle. Create and render videos from HTML: the contract, film, captions, overlays, motion and look references, the CLI, the registry, media, Figma, slideshows and Remotion ports.",
"homepage": "https://hyperframes.heygen.com",
"license": "Apache-2.0"
},
{
"name": "core-skills",
"source": "./",
"description": "Legacy core-only installation. For release-managed bundled workflows, install hyperframes@hyperframes and then uninstall core-skills@hyperframes.",
"description": "Legacy core-only installation. For the release-managed bundle of every skill, install hyperframes@hyperframes and then uninstall core-skills@hyperframes.",
"homepage": "https://hyperframes.heygen.com",
"license": "Apache-2.0",
"skills": [
"./skills/hyperframes",
"./skills/hyperframes-animation",
"./skills/hyperframes-audio",
"./skills/hyperframes-cli",
"./skills/hyperframes-core",
"./skills/hyperframes-creative",
"./skills/hyperframes-keyframes",
"./skills/hyperframes-registry",
"./skills/hyperframes-studio",
"./skills/media-use"
]
}
+14 -17
View File
@@ -1,13 +1,13 @@
---
name: captions-overlay
description: Overlay doctrine for the embedded-captions workflow — the caption MODEL (drop / rail / embed) and the rule that captions are an OVERLAY composited on top of the film, never a reserved bottom band you shift content up to avoid. Load when adding captions/subtitles to a talking-head or launch video, when deciding whether a phrase should be dropped, ride the verbatim rail, or be promoted to a scarce embedded climax, when laying out a composition that will carry captions (do NOT reserve a keep-out band), or when centering a composition on the true frame center under captions. Quotes the rail+embed model from embedded-captions and constraint #13 (captions overlay, keep-out band retired) from the product-launch-video scene agent. Applies ON TOP of embedded-captions.
description: Overlay doctrine for captions in HyperFrames videos — the caption MODEL (drop / rail / embed) and the rule that captions are an OVERLAY composited on top of the film, never a reserved bottom band you shift content up to avoid. Load when adding captions/subtitles to a talking-head or launch video, when deciding whether a phrase should be dropped, ride the verbatim rail, or be promoted to a scarce embedded climax, when laying out a composition that will carry captions (do NOT reserve a keep-out band), or when centering a composition on the true frame center under captions. Applies ON TOP of the `/hyperframes` skill's references/captions.md.
metadata:
internal: true
---
# Captions Overlay Doctrine
> **Overlay doctrine — supplements the upstream `embedded-captions` skill. Applies ON TOP of it; do not expect it folded into the upstream skill.**
> **Overlay doctrine — supplements the upstream `/hyperframes` skill's `references/captions.md`. Applies ON TOP of it; do not expect it folded into the upstream skill.**
Two ideas combine here. First, the **caption model** — every spoken phrase is `drop`,
`rail`, or `embed`, and embed is the scarce earned peak, not the default. Second, the
@@ -19,7 +19,7 @@ frame and centers on the true vertical center.
## The caption model — drop / rail / embed
Every spoken phrase is one of three things (verbatim from `embedded-captions`):
Every spoken phrase is one of three things:
| | What | How it's shown |
| --------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -31,15 +31,13 @@ Every spoken phrase is one of three things (verbatim from `embedded-captions`):
never two adjacent/co-visible, spaced ≥ a beat apart. A short clip → usually one embed;
a long explainer → ~one per section. Embedding every word is the common mistake.
This is the **Standard** mode shape (rail = the verbatim lower-third; embed = the climax
composited behind the subject). **Cinematic** mode drops the rail and makes everything
embed-style — use it only for pure-cinematic asks, never for explainer / voiceover where
the words must read.
This is the default shape (rail = the verbatim lower-third; embed = the climax
composited behind the subject). A pure-cinematic treatment drops the rail and makes
everything embed-style — use it only for pure-cinematic asks, never for explainer /
voiceover where the words must read.
### Rail-first, embed-scarce (the load-bearing rules)
Quoted from the `embedded-captions` non-negotiables:
- **Rail-first for talking-head / explainer.** Don't embed the whole transcript — most
text is the rail; embed only peaks. Embedding everything is the default mistake.
- **Embed is scarce + spaced.** ≤1 embed per sentence/beat, never two adjacent or
@@ -48,11 +46,10 @@ Quoted from the `embedded-captions` non-negotiables:
## The overlay law — captions are NOT a reserved band
In a generated launch composition, when captions are enabled, finalize composites a
**small, minimal word-by-word caption line** as an overlay layer ON TOP of the whole
film (a single text line, bottom-centered, roughly the bottom ~5-8% of canvas height).
It is an overlay, not a reserved zone (verbatim from constraint #13 of the
product-launch-video scene agent):
In a composed video with captions enabled, the caption line is a **small, minimal
word-by-word line** composited as an overlay layer ON TOP of the whole film (a single
text line, bottom-centered, roughly the bottom ~5-8% of canvas height). It is an
overlay, not a reserved zone:
- **Center the composition on the TRUE vertical center — y = H / 2** (landscape 540,
portrait 960). Do not shift content up to "make room" for captions; a composition
@@ -64,7 +61,7 @@ product-launch-video scene agent):
line sits — the overlay would fight it. Large imagery / cards / ambient content under
the captions is fine; the caption skin is designed to read over content.
- There is no machine keep-out gate (the old `captions.mjs keepout` check is retired).
Finalize snapshot QA judges caption-over-content legibility visually.
Snapshot review judges caption-over-content legibility visually.
**When captions are disabled:** identical positioning freedom — the overlay simply
doesn't exist.
@@ -74,8 +71,8 @@ doesn't exist.
The model says the rail rides **in front** and an embed is a rare word composited
**behind the subject** — both are layers added to footage that ships untouched. The
overlay law says the caption line is a layer composited **on top** of the whole film,
not a band carved out of the layout. So in both the captioning pipeline and the
launch-video pipeline, captions are an overlay you add, not a zone you reserve:
not a band carved out of the layout. So whether captions go over footage or over a
composed film, they are an overlay you add, not a zone you reserve:
- Keep the full frame; center on true center; let content run to the edges.
- Make the rail (or the small overlay caption line) carry the verbatim words.
-8
View File
@@ -94,14 +94,6 @@
// The relocated media-use .mjs files are agent-invoked scripts outside the
// import graph; keep the exemption scoped to that shipped script type.
"packages/cli/src/media-use/**/*.mjs",
// Agent-invoked motion-graphics tools co-located with their docs (run via
// `node <path>` per grounding/PROTOCOL.md / categories/maps/module.md
// prose), not import-graph reachable.
"skills/motion-graphics/grounding/**",
"skills/motion-graphics/categories/**",
// Agent-invoked reference materials (template + motion-primitive HTML, catalogs),
// forked by path by the frame-worker per SKILL.md prose, not import-graph reachable.
"skills/music-to-video/references/**",
// Bundled @font-face data (read at runtime via fs.readFileSync, invisible
// to the import graph) + its manual rebuild tool.
"skills/**/fonts/**",
-6
View File
@@ -170,7 +170,6 @@ jobs:
- "skills/gsap/**"
- "skills/media-use/**"
skills:
- "scripts/generate-captured-font-copies.mjs"
- "packages/cli/src/media-use/**"
- "scripts/check-media-use-copy-parity.test.mjs"
- "scripts/check-skill-import-boundaries.test.mjs"
@@ -192,7 +191,6 @@ jobs:
- ".claude-plugin/**"
- ".cursor-plugin/**"
- "scripts/package-agent-plugin*"
- "scripts/agent-plugin-captions.test.mjs"
- "scripts/set-version*"
- "LICENSE"
- ".codex-plugin/**"
@@ -716,8 +714,6 @@ jobs:
- name: Exercise npm-cli bootstrap command
run: >-
node --test
skills/hyperframes-animation/scripts/package-loader.test.mjs
skills/hyperframes-creative/scripts/package-loader.test.mjs
skills/hyperframes/scripts/plugin-cli.test.mjs
# Guards that skills-manifest.json (the published freshness fingerprint read
@@ -787,8 +783,6 @@ jobs:
run: node --test scripts/package-agent-plugin.test.mjs
- name: Build portable agent plugin
run: node scripts/package-agent-plugin.mjs
- name: Exercise captions from the extracted portable bundle
run: node --test scripts/agent-plugin-captions.test.mjs
- name: Build upload-ready Codex plugin
run: node scripts/package-codex-plugin.mjs
+4
View File
@@ -197,3 +197,7 @@ chrome/
# Hosted catalog item files are generated output the docs host serves; the rules above match their folder names
!docs/public/catalog/items/**/compositions/
!docs/public/catalog/items/*-ad/
# Python bytecode from running skill scripts
__pycache__/
*.pyc
+9 -2
View File
@@ -37,8 +37,6 @@ registry/blocks/orbit-card/assets/three.module.min.js
registry/blocks/orbit-card/assets/gsap-3.14.2.min.js
# generated Lottie data: pretty-printing multiplies its size
registry/blocks/lottie-character-walk/assets/mascot.json
# reference snippets with intentional pseudo-markup (literal "..." attributes)
skills/talking-head-recut/references/frames/polaroid.html
# repo-native skill examples with intentional pseudo-markup placeholders
# (e.g. <RANGE>, <TOTAL> tokens the agent replaces at build time — not real HTML)
.claude/skills/**/examples/**/*.html
@@ -49,3 +47,12 @@ skills/talking-head-recut/references/frames/polaroid.html
# explicitly (not registry/examples/**/*.html) so hand-authored example HTML still
# gets formatted.
registry/examples/warm-grain/compositions/captions.html
# Skill entries: agents read each one whole when it opens, so table padding costs tokens every time.
skills/hyperframes/SKILL.md
skills/hyperframes-cli/SKILL.md
skills/hyperframes-registry/SKILL.md
skills/media-use/SKILL.md
skills/figma/SKILL.md
skills/slideshow/SKILL.md
skills/remotion-to-hyperframes/SKILL.md
+11 -15
View File
@@ -4,27 +4,23 @@ Open-source video rendering framework: write HTML, render video.
## Skills
This repo ships AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover. **Default to the core set** — the `/hyperframes` router installs each creation workflow on demand; install everything only when the user explicitly asks for the full set.
This repo ships AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover. **Default to the core set** (`/hyperframes`, `/hyperframes-cli`, `/hyperframes-registry`, `/media-use`); install everything only when the user explicitly asks for the full set.
```bash
npx hyperframes skills update # default: installs/refreshes the core set — workflows install on demand
npx hyperframes skills # all 21 published skills at once
npx hyperframes skills update # default: installs/refreshes the core set plus anything already installed
npx hyperframes skills # all 7 published skills at once
npx skills add heygen-com/hyperframes # interactive picker (terminal only; repo-internal skills are excluded by default)
```
**Creation workflows** route through one entry skill — read `/hyperframes` first: it orients you to the whole surface, confirms the brief up front (the intent layer), and maps "make me a…" intent — usually a video, but also a navigable deck (`/slideshow`) or a composition port (`/remotion-to-hyperframes`) — to a concrete workflow. Consult it before invoking a specific workflow:
Make any video by asking for it; the `/hyperframes` skill carries the contract and the references (film, captions, overlays, motion, look). Read it first.
- `/product-launch-video` — any **website** URL (or a pre-written script / text brief in no-capture mode) → a product launch / promo video, or a site tour / showcase featuring the site's own captured screens; up to ~3 min (sweet spot ~30-90s).
- `/faceless-explainer` — arbitrary text, **no URL and no website capture** → faceless explainer, up to ~3 min (sweet spot ~30-90s); every visual is LLM-invented (typography / abstract graphics / diagram / data-viz).
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (verbatim rail + embedded climax, or pure-cinematic embed); the footage itself is untouched (no NLE-style editing).
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage packaged with designed **graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath, footage untouched. For plain captions/subtitles → `/embedded-captions`.
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → code-change explainer, up to ~3 min (changelog / feature reveal / fix / refactor). A PR link, not a product website.
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
- `/music-to-video` — a **music track** (audio file, video to pull audio from, or one generated from a mood brief) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
- `/general-video` — fallback for any other video creation (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes flow — design → plan → layout → build → validate, any length.
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) video composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
- `/hyperframes` — read first for any request to make, edit, animate or render a video, motion graphic, captions or overlays with HyperFrames (HTML rendered to video). Holds the render contract and the work loop, and points to the references for format, motion, look, captions, whole films and storyboards.
- `/hyperframes-cli` — the HyperFrames CLI by job: scaffold, check, look at frames, edit the timeline, preview, render locally or in the cloud, publish, project history; plus reading its JSON and recovering from build or render failures.
- `/hyperframes-registry` — about 450 ready-made blocks and components installed as editable source. Use before hand-building any named visual; also for `hyperframes add` and `catalog`.
- `/media-use` — get or make any media for a video (music, sound effects, images, voiceover, avatar and generated video, transcripts, cutouts, colour grades) with HeyGen or free local tools, and mix placed audio.
- `/figma` — bring Figma into a video: rendered assets, brand tokens, components, storyboard frames as reconstructed motion.
- `/slideshow` — a presentation, pitch deck or interactive deck. Output is a navigable deck, not a rendered MP4.
- `/remotion-to-hyperframes` — bring existing video code or footage into HyperFrames (Remotion first; also Motion Canvas, Lottie, Three.js, canvas, or renders from other tools) and verify it frame by frame against the original.
## Issue and PR triage
+15 -34
View File
@@ -4,11 +4,11 @@ Open-source video rendering framework: write HTML, render video.
## Skills
This repo ships 21 AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover. **Default to the core set**: the `/hyperframes` router installs each creation workflow on demand; install all 21 only when the user explicitly asks for the full set.
This repo ships 7 AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover. **Default to the core set** (`/hyperframes`, `/hyperframes-cli`, `/hyperframes-registry`, `/media-use`); `/slideshow`, `/remotion-to-hyperframes` and `/figma` install on demand. Install all 7 only when the user explicitly asks for the full set.
```bash
npx hyperframes skills update # default: installs/refreshes the core set — workflows install on demand
npx hyperframes skills # all 21 published skills at once — only on explicit request
npx hyperframes skills update # default: installs/refreshes the core set plus anything already installed
npx hyperframes skills # all 7 published skills at once — only on explicit request
npx skills add heygen-com/hyperframes # interactive picker; internal skills require INSTALL_INTERNAL_SKILLS=1 for discovery
npx skills add heygen-com/hyperframes --all # excludes internal skills unless INSTALL_INTERNAL_SKILLS=1
npx skills add heygen-com/hyperframes --skill <name> # a named match installs regardless of metadata.internal (bare name, no leading slash)
@@ -18,44 +18,25 @@ npx skills add heygen-com/hyperframes --skill <name> # a named match installs r
`skills add` resolves the skills.sh registry blob, which can lag `main` by hours, so a freshly added skill may be a little behind. `npx hyperframes skills update` installs from the current `main`; prefer it when freshness matters.
**`/hyperframes` is the entry skill — read it first.** It's the capability map for the domain skills below, the intent layer that confirms every creation brief up front, AND the intent router for the creation workflows. The full README skills section mirrors this list; keep them in sync (see "Skill catalog maintenance" below).
Make any video by asking for it; the `/hyperframes` skill carries the render contract and the references (format, runtimes, motion, look, captions, film, overlays, storyboards). The skills carry knowledge, not process. The README skills section mirrors this list; keep them in sync (see "Skill catalog maintenance" below).
### Creation workflows
- `/product-launch-video` — any **website** URL (or a pre-written script / text brief in no-capture mode) → a product launch / promo video, or a site tour / showcase featuring the site's own captured screens; up to ~3 min (sweet spot ~30-90s).
- `/faceless-explainer` — arbitrary text, **no URL and no website capture** → faceless explainer, up to ~3 min (sweet spot ~30-90s); every visual is LLM-invented (typography / abstract graphics / diagram / data-viz).
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → code-change explainer, up to ~3 min (changelog / feature reveal / fix / refactor). A PR link, not a product website.
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (verbatim rail + embedded climax, or pure-cinematic embed); the footage itself is untouched (no NLE-style editing).
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage packaged with designed **graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, PiP) synced to the transcript; the clip plays unchanged underneath, footage untouched. For plain captions/subtitles → `/embedded-captions`.
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
- `/music-to-video` — a **music track** (audio file, video to pull audio from, or one generated from a mood brief) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
- `/general-video` — fallback for any other video creation (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes flow — design → plan → layout → build → validate, any length.
- `/remotion-to-hyperframes` — port an existing Remotion (React) composition to HyperFrames HTML. One-way migration, not creation.
### Domain skills (loaded on demand)
Atomic capabilities the creation workflows compose against — pull one when you need that specific layer:
- `/hyperframes-core` — the composition contract: `data-*` timing attributes, `class="clip"`, tracks, sub-compositions, variables, framework-owned media playback, determinism rules. Read before writing composition HTML.
- `/hyperframes-animation` — all animation knowledge: atomic motion rules, scene blueprints, transitions, runtime adapters (GSAP default, plus Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU).
- `/hyperframes-keyframes` — seek-safe keyframe authoring across runtimes: GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, text trails, 3D depth; plus `hyperframes keyframes` diagnostics for surfacing and verifying rendered motion.
- `/hyperframes-creative` — non-animation creative direction: `frame.md` / `design.md` handling, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns.
- `/media-use` — the media OS (a host app's own music and sound-effect tools come first for those): resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record; generate via TTS / music / image models when the catalog misses; transcribe, caption, remove backgrounds, and reuse assets across projects. One shared `scripts/audio.mjs` engine + manifest tracking; keeps search noise on disk.
- `/hyperframes-audio` — mix the audio already placed in a composition: voiceover carve (dip a music bed only in the bands the voice occupies, static or dynamic, level match included), the effect chain (EQ, compressor, limiter, gate, saturation, delay, reverb, chorus, phaser, bitcrush), automation envelopes on volume or any effect parameter, and submix buses (`<hf-audio-group>`) that carry one chain, fader and automation clock for several tracks at once. Sourcing the audio is `/media-use`; this is what happens to it afterwards.
- `/hyperframes-cli` — CLI dev loop: `init`, `add`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, `lambda` (AWS Lambda cloud rendering).
- `/hyperframes-registry` — search, install and wire registry blocks and components into compositions via `hyperframes catalog` / `hyperframes add`. Load it before hand-building any named look, effect, treatment or transition: the search ranks the whole hosted registry with nothing installed. Covers authoring a new block or component to contribute upstream.
- `/figma` — import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition.
- `/hyperframes` — read first for any request to make, edit, animate or render a video, motion graphic, captions or overlays with HyperFrames (HTML rendered to video). Holds the render contract and the work loop, and points to the references for format, motion, look, captions, whole films and storyboards.
- `/hyperframes-cli` — the HyperFrames CLI by job: scaffold, check, look at frames, edit the timeline, preview, render locally or in the cloud, publish, project history; plus reading its JSON and recovering from build or render failures.
- `/hyperframes-registry` — about 450 ready-made blocks and components installed as editable source: transitions, overlays, titles, kinetic type, captions, charts, maps, 3D, camera moves. Use before hand-building any named visual; also for `hyperframes add` and `catalog`.
- `/media-use` — get or make any media for a video (music, sound effects, images, icons, logos, voiceover, avatar and generated video, transcripts, cutouts, colour grades and looks) with HeyGen or free local tools, and mix placed audio. Also for footage that looks dark, flat or dull, or should be stylized or hidden.
- `/figma` — bring Figma into a video: rendered assets, brand tokens, components, storyboard frames as reconstructed motion, shaders and connector-assisted motion. Use when the person pastes a figma.com link or asks to use a Figma design, frame, logo or brand.
- `/slideshow` — a presentation, pitch deck or interactive deck with discrete slides, fragment reveals, branching, hotspot navigation and presenter mode with speaker notes; also converts an existing page into a deck. Output is a navigable deck, not a rendered MP4.
- `/remotion-to-hyperframes` — bring existing video code or footage into HyperFrames (Remotion first; also Motion Canvas, Lottie, Three.js, canvas, or renders from other tools) and verify it frame by frame against the original.
## Skill catalog maintenance
When adding a new skill, or substantially renaming / repurposing an existing one, update all agent-facing discoverability surfaces in lockstep:
1. The skill list above (CLAUDE.md) AND the workflow list in the root `AGENTS.md` (it carries workflows only, no domain-skill section) AND the `## Skills` section in `README.md` AND `docs/guides/skills.mdx` (rendered at [hyperframes.heygen.com/guides/skills](https://hyperframes.heygen.com/guides/skills)) AND the two setup tables that compress the same descriptions — `docs/prompting/overview.mdx` ("One-time setup") and `docs/quickstart.mdx`. Out-of-date entries silently kill discovery. This list is also the sync set for a **changed contract**, not just an added or renamed skill: a reworded `description:` has to be pushed to every surface or the compressed copies start asserting the opposite of the skill.
1. The skill list above (CLAUDE.md) AND the skill list in the root `AGENTS.md` AND the `## Skills` section in `README.md` AND `docs/guides/skills.mdx` (rendered at [hyperframes.heygen.com/guides/skills](https://hyperframes.heygen.com/guides/skills)) AND the two setup pages that compress the same descriptions — `docs/prompting/overview.mdx` ("One-time setup") and `docs/quickstart.mdx`. Out-of-date entries silently kill discovery. This list is also the sync set for a **changed contract**, not just an added or renamed skill: a reworded `description:` has to be pushed to every surface or the compressed copies start asserting the opposite of the skill.
2. The scaffolded project template `packages/cli/src/templates/_shared/CLAUDE.md` + `AGENTS.md` — written into every `hyperframes init` project, so a stale entry there ships to users. The two template files must stay byte-identical.
3. If the skill changes the routing surface for "make a video" requests, also update the routing table + intent layer in `skills/hyperframes/SKILL.md` AND that workflow's own route file, `skills/hyperframes/references/routes/<workflow>.md`. One file carries both halves: the input/output/trigger contract the router reads before the workflow is installed, and its interview entry (must-haves, conditionals, deferred asks, run-shape). The older `references/workflow-catalog.md` and `references/route-briefs.md` are now "moved" stubs pointing at `routes/` — don't edit them.
4. Mirror the Router / Creation workflows / Domain skills grouping across all surfaces so a skill always lives in the same column.
5. Skill count appears in the README and CLAUDE.md intro lines ("20 AI agent skills…") — update on add/remove. The `docs/guides/skills.mdx` page and the CLI templates deliberately omit a count to avoid drift; keep them count-free.
3. The routing surface for "make a video" requests is the References table and closing line of `skills/hyperframes/SKILL.md`. A new reference or skill that a request should reach gets a row or a mention there.
4. Keep the same grouping on every surface: the core set (`/hyperframes`, `/hyperframes-cli`, `/hyperframes-registry`, `/media-use`) first, then the on-demand skills (`/figma`, `/slideshow`, `/remotion-to-hyperframes`).
5. Skill count appears in the README and CLAUDE.md intro lines ("7 AI agent skills…") — update on add/remove. The `docs/guides/skills.mdx` page and the CLI templates deliberately omit a count to avoid drift; keep them count-free.
The skill's own `SKILL.md` frontmatter `description:` is the source of truth for the one-line "use when" blurb; copy from there into the catalog rather than paraphrasing.
+17 -37
View File
@@ -53,7 +53,7 @@ For standalone skills (including OpenCode), use:
npx skills add heygen-com/hyperframes
```
> The picker opens with nothing pre-selected — the **Core Skills** group is all you need: the `/hyperframes` router installs each creation workflow on demand. Agents and non-interactive runs should use `npx hyperframes skills update` instead — it installs exactly the core set, whereas `skills add --all` installs all 21 published skills. The six repo-internal skills under `.claude/skills` / `.agents/skills` are excluded by default. For the full published set use `npx hyperframes skills`.
> The picker opens with nothing pre-selected — the **Core Skills** group is all you need; `/slideshow`, `/remotion-to-hyperframes` and `/figma` install on demand. Agents and non-interactive runs should use `npx hyperframes skills update` instead — it installs exactly the core set, whereas `skills add --all` installs all 7 published skills. The six repo-internal skills under `.claude/skills` / `.agents/skills` are excluded by default. For the full published set use `npx hyperframes skills`.
>
> `skills add` resolves the skills.sh registry blob, which can lag `main` by hours. `npx hyperframes skills update` installs from the current `main`, so reach for it when you need the newest copy of a skill.
@@ -65,11 +65,11 @@ The skills teach agents the HyperFrames production loop: plan the video, write v
## Skills
HyperFrames ships 21 skills agents load on demand. Read `/hyperframes` first — it's the router and capability map; it picks a workflow for any "make me a…" request — video, deck, or composition port — and points to the domain skills below.
HyperFrames ships 7 skills. Make any video by asking for it; the `/hyperframes` skill carries the contract and the references (film, captions, overlays, motion, look). Read it first.
For standalone installation, default to the **core set** — the router installs each creation workflow on demand. `npx hyperframes skills update` installs exactly that from anywhere; the interactive picker (`npx skills add heygen-com/hyperframes`) lists it as the "Core Skills" group, nothing pre-selected. The picker is interactive-only — a non-interactive or agent run without `--skill` installs all 21. Use `npx skills add heygen-com/hyperframes --all` to install the complete published set deliberately (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).
For standalone installation, default to the **core set** (`/hyperframes`, `/hyperframes-cli`, `/hyperframes-registry`, `/media-use`). `npx hyperframes skills update` installs exactly that from anywhere; the interactive picker (`npx skills add heygen-com/hyperframes`) lists it as the "Core Skills" group, nothing pre-selected. The picker is interactive-only — a non-interactive or agent run without `--skill` installs all 7. Use `npx skills add heygen-com/hyperframes --all` to install the complete published set deliberately (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).
Standalone installs stay lean after that: `npx hyperframes init` keeps the **core set** fresh (the router, the `hyperframes-*` domain skills, and `media-use` — plus whatever is already installed; `/figma` stays on demand) and never expands a partial install; the creation workflows install **on demand** — the router runs `npx hyperframes skills update <workflow>` before entering one. Nothing re-pulls the full set behind your back.
Standalone installs stay lean after that: `npx hyperframes init` keeps the **core set** fresh (plus whatever is already installed) and never expands a partial install; `/figma`, `/slideshow` and `/remotion-to-hyperframes` install **on demand** with `npx hyperframes skills update <name>`. Nothing re-pulls the full set behind your back.
### Plugin packages
@@ -87,42 +87,22 @@ bun run package:codex-plugin
This writes `dist/hyperframes-plugin.zip` with a `hyperframes/` root folder and fails if the archive exceeds Codex's 100 MB upload limit.
### Router
### Core set
| Skill | Use when |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/hyperframes` | **Read first** for any request to make / create / edit / animate / render a video, animation, or motion graphic. Capability map for the domain skills, the intent layer that confirms every creation brief up front, and intent router for the creation workflows below. |
| Skill | Use when |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/hyperframes` | **Read first** for any request to make, edit, animate or render a video, motion graphic, captions or overlays with HyperFrames (HTML rendered to video). Holds the render contract and the work loop, and points to the references for format, motion, look, captions, whole films and storyboards. |
| `/hyperframes-cli` | The HyperFrames CLI by job — scaffold, check, look at frames, edit the timeline, preview, render locally or in the cloud, publish, project history — plus reading its JSON and recovering from build or render failures. |
| `/hyperframes-registry` | About 450 ready-made blocks and components installed as editable source — transitions, overlays, titles, kinetic type, captions, charts, maps, 3D, camera moves. Use before hand-building any named visual; also for `hyperframes add` and `catalog`. |
| `/media-use` | Get or make any media for a video — music, sound effects, images, icons, logos, voiceover, avatar and generated video, transcripts, cutouts, colour grades and looks — with HeyGen or free local tools, and mix placed audio. Also for footage that looks dark, flat or dull. |
### Creation workflows
### On demand
| Skill | Use when |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/product-launch-video` | Any **website** — marketing / launching / promoting a product (from its URL, a brief, or a script), or a site tour / showcase / social clip featuring the site's own visuals. Up to ~3 min (sweet spot 30-90s). |
| `/faceless-explainer` | **Explaining a topic / concept** from arbitrary text — no product, no URL, no website capture; every visual is LLM-invented (typography / abstract / diagram / data-viz). |
| `/pr-to-video` | A **GitHub pull request** (PR URL, `owner/repo#N` ref, or "this PR") → changelog / feature-reveal / fix / refactor explainer, read via the `gh` CLI. |
| `/embedded-captions` | Adding **captions / subtitles** to an existing talking-head video (footage untouched) — verbatim rail, embedded climax behind the subject, or pure-cinematic embed. |
| `/talking-head-recut` | Packaging an existing talking-head / interview / podcast video with **designed graphic overlays** — lower-thirds, data callouts, kinetic titles, pull-quotes, side panels, PiP. |
| `/motion-graphics` | A short, **unnarrated, design-led motion graphic** (~under 10s) — kinetic type, stat / chart hit, logo sting, lower-third, animated tweet / headline. MP4 or transparent overlay. |
| `/music-to-video` | A **music track** (audio file, video to pull audio from, or one generated from a mood brief) → a **beat-synced** video — lyric, slideshow, or kinetic promo; music drives pacing. |
| `/slideshow` | A **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video. |
| `/general-video` | **Anything else** — longer or multi-scene pieces, brand / sizzle reel, title card, static loop, freeform composition. Input- and length-agnostic fallback, and the home of companion mode (co-create with the full toolbox). |
| `/remotion-to-hyperframes` | **Porting an existing Remotion** (React) composition's source to HyperFrames HTML. One-way migration, not creation. |
### Domain skills (loaded on demand)
Atomic capabilities the creation workflows compose against — pull one when you need that specific layer.
| Skill | Covers |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/hyperframes-core` | The composition contract — `data-*` timing attributes, `class="clip"`, tracks, sub-compositions, variables, framework-owned media playback, determinism rules. |
| `/hyperframes-animation` | All animation knowledge — atomic motion rules, scene blueprints, transitions, runtime adapters (GSAP / Lottie / Three.js / Anime.js / CSS / WAAPI / TypeGPU). |
| `/hyperframes-keyframes` | Seek-safe keyframe authoring across runtimes — GSAP timelines, CSS keyframes, Anime.js, WAAPI, FLIP, paths, masks, SVG morph/draw, 3D depth — plus `hyperframes keyframes` diagnostics for rendered motion. |
| `/hyperframes-creative` | Non-animation creative direction — `frame.md` / `design.md`, palettes, typography, narration, beat planning, audio-reactive visuals, composition patterns. |
| `/media-use` | The media OS (a host app's own music and sound-effect tools come first for those) — resolve any media need (BGM, SFX, image, icon, logo, voice, color grade, LUT) into a frozen local file or paste-ready block + ledger record, generate via TTS/music/image models when the catalog misses, transcribe, caption, remove backgrounds, and reuse assets across projects. One shared audio engine + manifest tracking. |
| `/hyperframes-cli` | CLI dev loop — `init`, `lint`, `check`, `snapshot`, `preview`, `render`, `publish`, `doctor`, plus HeyGen-hosted cloud rendering (`cloud render`) and AWS Lambda rendering (`lambda deploy / render / progress`). |
| `/hyperframes-audio` | Mix the audio already placed in a composition — voiceover carve (dip a music bed only in the bands the voice occupies, static or dynamic, level match included), the effect chain (EQ, compressor, limiter, gate, saturation, delay, reverb, chorus, phaser, bitcrush), automation envelopes on volume or any effect parameter, and submix buses (`<hf-audio-group>`) carrying one chain, fader and automation clock for several tracks at once. Sourcing the audio is `/media-use`. |
| `/hyperframes-registry` | Search, install and wire registry blocks and components into compositions via `hyperframes catalog` / `hyperframes add`. Load before hand-building any named look, effect, treatment or transition. Authoring a new block or component to contribute upstream. |
| `/figma` | Import Figma assets, tokens, components, and storyboard sections → reconstructed motion (frames read as states, not slides) (REST/CLI) plus Motion animations (MCP) and shaders (MCP source / native export) into a composition. |
| Skill | Use when |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/figma` | Bring Figma into a video — rendered assets, brand tokens, components, storyboard frames as reconstructed motion, shaders and connector-assisted motion. Use when you paste a figma.com link. |
| `/slideshow` | A presentation, pitch deck or interactive deck — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode with speaker notes. Output is a navigable deck, not a rendered MP4. |
| `/remotion-to-hyperframes` | Bringing existing video code or footage into HyperFrames (Remotion first; also Motion Canvas, Lottie, Three.js, canvas, other tools' renders), verified frame by frame. |
For visual design handoff workflows, see the [Claude Design guide](https://hyperframes.heygen.com/guides/claude-design) and [Open Design guide](https://hyperframes.heygen.com/guides/open-design).
@@ -107,7 +107,7 @@ The default policy doc emitted by `hyperframes lambda policies user/role` uses `
## Migration checklist
1. **Inventory** the compositions you want to migrate. Filter out anything that needs HDR — that stays on your current framework for now. webm renders distributed via closed-GOP VP9 + concat-copy (see the webm section above).
2. **Translate** each composition to plain HTML. The `[Concepts](/concepts)` page covers the data-attribute conventions; installing the skills (`npx hyperframes skills update`) makes Claude / Cursor / Codex aware of them too — start at `/hyperframes`, which routes to `/hyperframes-core` for the composition contract.
2. **Translate** each composition to plain HTML. The `[Concepts](/concepts)` page covers the data-attribute conventions; installing the skills (`npx hyperframes skills update`) makes Claude / Cursor / Codex aware of them too — start at `/hyperframes`, which carries the composition contract and points to `references/format.md` for the details.
3. **Wire** the new composition into your build pipeline alongside the old one. HyperFrames doesn't need an external bundler — you can `npx hyperframes preview` against the HTML directly.
4. **Deploy** in a separate AWS account or with a `--stack-name=hyperframes-staging` first. Run a real render with `--wait`; verify the output bytes.
5. **Add the policy** to your CI. `hyperframes lambda policies user > infra/iam/hyperframes.json` then `hyperframes lambda policies validate infra/iam/hyperframes.json` on every PR.
+1 -1
View File
@@ -206,5 +206,5 @@ The init templates remain available for scaffolding complete projects.
## Related topics
- [Make your first video](/quickstart)
- [Choose a creation workflow](/workflows)
- [Choose what to make](/workflows)
- [Take more control of an existing project](/go-further)
+9 -10
View File
@@ -31,15 +31,14 @@ quotes, statistics, lower thirds, or picture-in-picture.
## What should change?
This page covers two workflows. `/hyperframes` picks between them from what you
ask for, and you can name one directly if you already know which you want.
Say which of these you want; `/hyperframes` handles both.
- **Words on screen** — `/embedded-captions`. Readable, timed words over the clip.
Plain subtitles, or cinematic captions set behind the subject.
- **Designed cards on screen** — `/talking-head-recut`. Titles, pull-quotes, data
callouts, lower thirds, picture-in-picture, synced to the transcript.
- **Change the footage itself** — neither of these. Use
[General Video](/guides/general-video) when pauses, selected moments, or the
- **Words on screen** — readable, timed words over the clip. Plain subtitles, or
cinematic captions set behind the subject.
- **Designed cards on screen** — titles, pull-quotes, data callouts, lower
thirds, picture-in-picture, synced to the transcript.
- **Change the footage itself** — neither of these. Make a
[custom video](/guides/general-video) when pauses, selected moments, or the
spoken order must change.
Both leave the clip and its audio untouched: the first adds words, the second adds
@@ -47,7 +46,7 @@ graphics, and the footage plays the same underneath either way.
Captions and designed overlays work best on a clear, single-subject clip with audible speech. Both keep the original footage and audio intact.
If you need to remove pauses, choose different moments, or reorder the speech, use [General Video](/guides/general-video).
If you need to remove pauses, choose different moments, or reorder the speech, make a [custom video](/guides/general-video).
## Before it builds
@@ -78,4 +77,4 @@ style changes.
- [Edit captions in Studio](/studio/captions)
- [Work with voice, music, sound, and captions](/guides/voice-and-audio)
- [Change the spoken edit with a custom video workflow](/guides/general-video)
- [Change the spoken edit with a custom video](/guides/general-video)
+3 -4
View File
@@ -1232,8 +1232,7 @@ tl.to("#s5-headline", { backgroundSize: "100% 30%", duration: 0.6, ease: "power2
Everything critical is inlined above. These are for edge cases:
- Core composition contract (data attributes, sub-comp wiring): https://github.com/heygen-com/hyperframes/blob/main/skills/hyperframes/SKILL.md
- Motion theory (easing as emotion, direction rules): https://github.com/heygen-com/hyperframes/blob/main/skills/hyperframes-creative/references/motion-principles.md
- Typography (full banned list, weight contrast, OpenType): https://github.com/heygen-com/hyperframes/blob/main/skills/hyperframes-creative/references/typography.md
- Transitions (shader catalog, CSS transition patterns): https://github.com/heygen-com/hyperframes/blob/main/skills/hyperframes-animation/transitions/overview.md
- Captions synced to audio: https://github.com/heygen-com/hyperframes/blob/main/skills/embedded-captions/SKILL.md
- Motion and transitions (timings, eases, cuts, title entrances): https://github.com/heygen-com/hyperframes/blob/main/skills/hyperframes/references/motion.md
- Look (colours, type, presets, frame.md): https://github.com/heygen-com/hyperframes/blob/main/skills/hyperframes/references/look.md
- Captions synced to audio: https://github.com/heygen-com/hyperframes/blob/main/skills/hyperframes/references/captions.md
- Full docs: https://hyperframes.heygen.com/
+2 -2
View File
@@ -45,8 +45,8 @@ When I approve it, render an MP4 to renders/final.mp4.
```
Use the agent when a failed check may require a source change or when you want
one person to manage the complete finish. HyperFrames creation workflows wait
for render approval after the final preview.
one person to manage the complete finish. The `/hyperframes` skill waits for
render approval after the final preview.
### Use Studio
+6 -8
View File
@@ -1,12 +1,12 @@
---
title: "Create a custom video"
sidebarTitle: "Custom video"
description: "Combine sources, change existing footage, or co-direct a video when no focused workflow fits."
description: "Combine sources, change existing footage, or co-direct a video when no single source decides the job."
---
import { DocsVideo } from "/snippets/docs-video.jsx";
Use General Video when the work is genuinely custom: several source types, editable footage, a longer multi-scene story, an unusual format, or close creative collaboration.
Use this path when the work is genuinely custom: several source types, editable footage, a longer multi-scene story, an unusual format, or close creative collaboration.
<Frame caption="A complete launch film about timeline editing. The custom route combines the story, product evidence, motion, voice, music, and sound.">
<DocsVideo
@@ -27,8 +27,8 @@ and office footage in assets. Keep the employees' own words central.
```
The agent proposes a treatment and confirms the important intent before the
build. Use this path when choosing the story together matters more than fitting
one predefined input-to-output workflow.
build. Use this path when choosing the story together matters more than starting
from one source.
## How should the agent work with you?
@@ -36,8 +36,6 @@ one predefined input-to-output workflow.
- **Review the story first** — approve the storyboard and rough layouts before detailed production.
- **Co-direct it** — shape the story, visual system, motion, transitions, audio, opening, and ending with the agent.
In companion mode, the agent proposes a complete creative treatment first. You redirect or remove what does not serve the idea; you do not have to request quality layer by layer.
## Use the custom route when
- several kinds of source material must become one video;
@@ -46,7 +44,7 @@ In companion mode, the agent proposes a complete creative treatment first. You r
- the same idea needs deliberately redesigned versions for different formats;
- you want to shape the creative treatment with the agent.
If one source already determines the job—a product URL, article, pull request, track, talking-head clip, or short motion graphic—start with its [focused workflow](/workflows).
If one source already determines the job—a product URL, article, pull request, track, talking-head clip, or short motion graphic—start from [that source's guide](/workflows).
## Keep feedback useful
@@ -68,5 +66,5 @@ Before export, confirm:
## Related topics
- [Choose a focused workflow](/workflows)
- [Choose what to make](/workflows)
- [Edit the built project in Studio](/studio)
+1 -1
View File
@@ -71,7 +71,7 @@ The registry key must match `data-composition-id`.
5. Animate transforms and opacity for movement. Avoid repeatedly animating layout properties such as `top`, `left`, `width`, or `height`.
6. Let HyperFrames control clip visibility and media playback.
The normal timeline methods are `to()`, `from()`, `fromTo()`, and `set()`. GSAP supports many CSS properties, but the [HyperFrames animation skill](https://github.com/heygen-com/hyperframes/tree/main/skills/hyperframes-animation) contains the render-safe patterns used by agents.
The normal timeline methods are `to()`, `from()`, `fromTo()`, and `set()`. GSAP supports many CSS properties, but the [HyperFrames animation skill](https://github.com/heygen-com/hyperframes/tree/main/skills/hyperframes/animation) contains the render-safe patterns used by agents.
## Load fonts before measuring text
+3 -3
View File
@@ -15,7 +15,7 @@ Use Motion Graphics for one short, design-led idea—usually under ten seconds
poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/motion-graphics-demo-v1.jpg"
/>
Short beats back to back, because that is what this workflow makes — units of
Short beats back to back, because that is what a motion graphic is — units of
motion small enough to drop into something else.
## Make the request
@@ -40,9 +40,9 @@ optional unless a specific moment must land precisely.
- **Overlay** — create a lower third or callout that enters, holds, and clears completely over footage.
- **Page, post, or map** — capture the real source and animate one detail into the visual payoff.
The same workflow can animate kinetic type, a statistic, chart, logo, lower third, map, real webpage, news headline, or post.
The same request shape can animate kinetic type, a statistic, chart, logo, lower third, map, real webpage, news headline, or post.
If the result needs narration, several scenes, or a longer story, use [General Video](/guides/general-video).
If the result needs narration, several scenes, or a longer story, make a [custom video](/guides/general-video).
## Choose the output
+6 -8
View File
@@ -4,9 +4,9 @@ sidebarTitle: "Agent plugins"
description: "Install video creation skills as one versioned plugin and let your agent manage updates."
---
The HyperFrames plugin gives your coding agent the complete set of video workflows:
product promos, explainers, captions, motion graphics, and more. Skills load when
needed; you do not have to install each workflow separately.
The HyperFrames plugin gives your coding agent every HyperFrames skill: the video
contract and references for product promos, explainers, captions, motion graphics,
and more, plus the CLI, registry, and media skills. Skills load when needed.
Choose one installation method per agent. Plugins receive updates through the
agent's plugin manager. [Standalone skills](/guides/skills) remain available for
@@ -35,7 +35,7 @@ claude plugin install hyperframes@hyperframes
claude plugin details hyperframes
```
The inventory should include `hyperframes`, `general-video`, and `media-use`.
The inventory should include `hyperframes`, `hyperframes-cli`, and `media-use`.
Start a new session or run `/reload-plugins`, then try:
```text
@@ -126,10 +126,8 @@ To stay on a specific release, install with `--ref <release-tag>` instead of
`--auto-update`. Choose a release containing `gemini-extension.json`.
See [Gemini's extension guide](https://geminicli.com/docs/extensions/reference/).
The embedded-captions workflow additionally needs project-local Sharp, Puppeteer
with Chromium, and GSAP. Its skill includes the versioned installation command;
keep those dependencies and their lockfile in the caption project. The plugin
itself remains read-only and does not include browsers or model weights.
The plugin itself remains read-only and does not include browsers or model
weights.
## Versions and updates
+2 -2
View File
@@ -9,12 +9,12 @@ import { DocsVideo } from "/snippets/docs-video.jsx";
Give HyperFrames the real URL when the product, website, or brand should appear in the result.
<DocsVideo
title="A launch film built by this workflow from huly.io, end to end"
title="A launch film built from huly.io, end to end"
src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/product-launch-huly-v5.mp4"
poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/product-launch-huly-v5.jpg"
/>
Built by running this workflow on a real site. The screens are captured from
Built from a real site. The screens are captured from
huly.io, not mocked — which is the point: you give it a URL and it works from
what is actually there.
+22 -25
View File
@@ -1,22 +1,21 @@
---
title: "Install and update agent skills"
sidebarTitle: "Agent skills"
description: "Teach a coding agent how to create valid HyperFrames projects and load specialized workflows when needed."
description: "Teach a coding agent how to create valid HyperFrames projects."
---
import { DocsVideo } from "/snippets/docs-video.jsx";
HyperFrames skills teach coding agents the project format, animation rules, media workflow, validation loop, and creation workflows that generic web knowledge does not cover.
HyperFrames skills teach coding agents the project format, animation rules, media workflow, and validation loop that generic web knowledge does not cover.
<DocsVideo
title="One command installs the skills, and the router picks the workflow"
title="One command installs the skills"
src="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/skills-install-demo-v2.mp4"
poster="https://static.heygen.ai/hyperframes-oss/docs/images/showcase/skills-install-demo-v2.jpg"
/>
The terminal output is a real run of the command. Afterwards the router reads a
request and chooses the workflow for it, instead of the agent guessing at the
framework's rules.
The terminal output is a real run of the command. Afterwards the agent reads
`/hyperframes` before it builds, instead of guessing at the framework's rules.
Prefer a [versioned plugin](/guides/plugins) when your agent supports one. This
page covers standalone installs, including OpenCode. Plugin users should update
@@ -30,7 +29,7 @@ Run the interactive installer:
npx skills add heygen-com/hyperframes
```
Select **Core Skills**. The `/hyperframes` router installs a specialized creation workflow when the request needs one, so you do not need the full catalog up front.
Select **Core Skills**. That covers every kind of video; the other skills install when a request needs them, so you do not need the full catalog up front.
Start a fresh agent chat after installation, then ask it to use `/hyperframes`.
@@ -42,7 +41,7 @@ For a non-interactive agent or CI environment, use:
npx hyperframes skills update
```
This installs or refreshes the core set and any HyperFrames skills already present in the global install, then links those bundles into compatible installed agents. It does not add every optional workflow.
This installs or refreshes the core set and any HyperFrames skills already present in the global install, then links those bundles into compatible installed agents. It does not add every optional skill.
Check freshness without changing anything:
@@ -50,10 +49,10 @@ Check freshness without changing anything:
npx hyperframes skills check
```
Install or refresh one workflow explicitly:
Install or refresh one skill explicitly:
```bash
npx hyperframes skills update pr-to-video
npx hyperframes skills update slideshow
```
## Use the skills in GitHub Copilot CLI
@@ -106,23 +105,21 @@ bob
Ask Bob to use `/hyperframes` for the first request. Running the update again
refreshes the same global skill bundles without changing the project format.
## Let the router choose
## What the skills cover
The normal entry point is `/hyperframes`. It confirms the important intent, then selects a workflow from the input:
Make any video by asking for it; the `/hyperframes` skill carries the contract and the references (each kind of film, story, motion, look, sound, review, captions, overlays).
| You provide | Workflow |
| Skill | What it covers |
| --- | --- |
| A product website or product brief | Product launch video |
| A topic or script with no website capture | Faceless explainer |
| A GitHub pull request | PR-to-video |
| Existing talking-head footage | Captions or a designed recut |
| A music track | Music-driven video |
| A short design-led animation | Motion graphic |
| A presentation | Slideshow |
| A Figma file or frame | Figma import with assets, brand tokens, components, and reconstructed motion |
| Anything outside those shapes | General video |
| `/hyperframes` | Read first for any request to make, edit, animate or render a video, motion graphic, captions or overlays. Holds the render contract and the work loop, and points to the references for format, motion, look, captions, whole films and storyboards. |
| `/hyperframes-cli` | The CLI by job: scaffold, check, look at frames, edit the timeline, preview, render locally or in the cloud, publish, project history. |
| `/hyperframes-registry` | About 450 ready-made blocks and components installed as editable source: transitions, overlays, titles, kinetic type, captions, charts, maps, 3D, camera moves. |
| `/media-use` | Music, sound effects, images, icons, logos, voiceover, avatar and generated video, transcripts, cutouts, colour grades and looks, with HeyGen or free local tools; mixes placed audio. |
| `/figma` | A Figma file or frame: rendered assets, brand tokens, components, and storyboard frames as reconstructed motion. |
| `/slideshow` | A presentation, pitch deck or interactive deck. Output is a navigable deck, not a rendered MP4. |
| `/remotion-to-hyperframes` | Bringing existing video code or footage into HyperFrames (Remotion first; also Motion Canvas, Lottie, Three.js, canvas, other tools' renders), verified frame by frame. |
You can invoke a workflow directly when you already know it is the right one, but you do not need to memorize the names to start.
You can name a skill directly, but you do not need to memorize the names to start.
## Install more only when you need it
@@ -137,7 +134,7 @@ npx hyperframes skills
Or install one named skill:
```bash
npx skills add heygen-com/hyperframes --skill hyperframes-animation
npx skills add heygen-com/hyperframes --skill slideshow
```
Use the full set for an environment that must work across many HyperFrames tasks without fetching later. For a normal project, the core set is smaller and easier to keep current.
@@ -155,5 +152,5 @@ available in the [HyperFrames repository](https://github.com/heygen-com/hyperfra
## Related topics
- [Make your first video](/quickstart)
- [Choose a specific creation workflow](/workflows)
- [Choose what to make](/workflows)
- [Use authentication and hosted services](/guides/authentication)
+1 -1
View File
@@ -33,7 +33,7 @@ The agent confirms the audience, main outcome, and whether the deck needs
reveals, optional branches, or presenter notes. You do not need to prescribe
every slide before seeing the proposed structure.
If the result should play from beginning to end without a presenter, use a normal [video workflow](/workflows). A slideshow cannot be exported as one linear MP4 yet.
If the result should play from beginning to end without a presenter, make a normal [video](/workflows). A slideshow cannot be exported as one linear MP4 yet.
<Warning>
Do not run `hyperframes render` on a deck. It does not fail — a deck has no
+2 -2
View File
@@ -110,8 +110,8 @@ graphic overlays, and an actual change to the spoken edit.
- Listen on ordinary headphones and laptop speakers.
When narration drives the project, time visual changes to its real transcript
rather than estimated scene lengths. When music drives the project, use the
[music-video workflow](/guides/music-to-video) and its analyzed structure.
rather than estimated scene lengths. When music drives the project, cut to its analyzed structure (see
[music to video](/guides/music-to-video)).
## Review the complete sound pass
+39 -13
View File
@@ -121,8 +121,9 @@ that off.
`--tailwind` injects the pinned Tailwind v4 browser runtime and exposes a
`window.__tailwindReady` promise that renders wait on before capturing frame 0.
Use the `/hyperframes-core` skill when editing these projects, so agents follow
v4 CSS-first patterns instead of v3 `tailwind.config.js` patterns. The browser
The `/hyperframes-cli` skill's `references/init-and-scaffold.md` (Tailwind)
covers editing these projects, so agents follow v4 CSS-first patterns instead
of v3 `tailwind.config.js` patterns. The browser
runtime is meant for scaffolded projects and quick iteration — for offline or
locked-down production renders, compile Tailwind to CSS and link the stylesheet.
@@ -271,8 +272,7 @@ You get `AGENTS.md`, `CLAUDE.md`, `meta.json`, scroll screenshots, extracted
HTML and CSS, visible text, design tokens, font files, images, SVGs, animation
metadata, and contact sheets — plus whatever Lottie, video, and WebGL context
the page exposed. It is raw material for an agent, not a finished composition;
the `/product-launch-video` workflow uses it when a real product has to appear
on screen. Dynamic sites, protected pages, and unusual media loaders produce
the `/hyperframes` skill uses it when a real product has to appear on screen. Dynamic sites, protected pages, and unusual media loaders produce
partial results, so read `dropped`, the warnings, and the contact sheets before
you build.
@@ -838,7 +838,7 @@ did, in one command and one browser session.
```bash
npx hyperframes check [dir]
npx hyperframes check [dir] --json # {ok, browserSkipped, scope, lint, runtime, layout, motion, contrast, hdr, snapshots}
npx hyperframes check [dir] --json # {ok, browserSkipped, scope, lint, runtime, layout, motion, contrast, craft, hdr, snapshots}
npx hyperframes check [dir] --snapshots
npx hyperframes check [dir] --at 1.5,4,7.25
npx hyperframes check [dir] --strict # warnings fail too
@@ -848,7 +848,8 @@ npx hyperframes check [dir] --strict # warnings fail too
Then it loads the bundled composition once and sweeps a single seek grid,
running every audit at every sample: runtime console errors and failed requests,
layout defects (overflow, clipping, held overlaps, occlusion, coordinate-frame
drift), `*.motion.json` assertions, and WCAG AA contrast.
drift), `*.motion.json` assertions, WCAG AA contrast, and craft warnings
(see [Craft warnings](#craft-warnings)).
When the browser never ran (lint errors, a linter crash, or a browser launch
failure), `browserSkipped` is `true` and the `layout`, `motion` and `contrast`
@@ -867,7 +868,7 @@ cover every finding from the full check.
| `--at` | Explicit timestamps in seconds, comma-separated |
| `--at-transitions` | Also sample every tween start and end, to catch transient overlaps at transition seams |
| `--max-transition-samples` | Cap the transition-derived samples. When it truncates, the omitted count is reported. Default: unlimited. |
| `--range <start,end>` | Sample only this interval, in global seconds. Lint stays project-wide; whole-timeline motion and `sweep_static` are skipped. |
| `--range <start,end>` | Sample only this interval, in global seconds. Lint stays project-wide; whole-timeline motion, `sweep_static` and craft warnings are skipped. |
| `--components <selectors>` | Report only findings that involve these elements or their descendants. Counts, `ok` and the exit code still cover all. |
| `--max-issues` | Findings to print or return after static collapse (default 80) |
| `--collapse-static` / `--no-collapse-static` | Fold a repeated static finding into one row (default: on) |
@@ -916,6 +917,28 @@ meant to be still can declare `data-no-timeline` to skip the guard.
{/* VISUAL: a `check --snapshots` overview frame beside one finding crop, with the labelled finding box visible on both */}
#### Craft warnings
The `craft` section reports what a viewer would notice over time and a single
frame cannot show. Every craft finding is a warning, even under `--strict`: a
film may break one on purpose. Its message names the fix and the skill reference to read.
They are measured on an 8 fps sweep of the whole film, with each short line's
edges re-measured to about 0.01 s, and skipped for a `--range` check.
| Code | Warns when |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `line_unread` | A reading-size line (30 px or taller at 1080) is on screen for less than its floor: 0.4 s for one or two words, otherwise three quarters of 0.5 s + 0.3 s a word. The film's last line needs 2 s. |
| `dead_stretch` | Nothing on screen changes for more than 3 s, by the same classifier `keepsMoving` uses. |
| `text_below_floor` | Text under 2.6% of the frame's short side (28 px on 1080) stays on screen 0.5 s or more. One finding per file lists every such element. |
| `first_frame_empty` | Frame 0 paints nothing but the background: no text, image, video, canvas or SVG. The first frame is the thumbnail on social players. |
| `film_silent` | A film longer than 15 s has no unmuted `<audio>` and no `<video>` with sound. |
Captions-track text (`data-track-kind="captions"`) and `data-layout-ignore`
text are exempt from the text rules. `dead_stretch` never counts the film's
last 3 s (a held end card) or time when an element marked `data-hold` is on
screen, and it stays quiet when `sweep_static` already reports the sweep or a
whole-composition `keepsMoving` assertion sets its own limit.
#### Mark intentional layout choices
When the audit is wrong because the layout is deliberate, say so in the HTML and
@@ -957,7 +980,7 @@ HTML. Without a sidecar, nothing changes.
| `appearsBy(selector, bySec)` | A reveal the seek lands past — the element must reach opacity ≥ 0.5 by `bySec` (`motion_appears_late`) |
| `before(a, b)` | Broken stagger order — `a` must first appear strictly before `b` (`motion_out_of_order`) |
| `staysInFrame(selector)` | Off-frame drift — once visible, the box never leaves the canvas (`motion_off_frame`) |
| `keepsMoving(withinSelector?)` | A frozen shot — no fully static window longer than `maxStaticSec`, default 2s (`motion_frozen`) |
| `keepsMoving(withinSelector?)` | A frozen shot — no fully static window longer than `maxStaticSec`, default 2s (`motion_frozen`); the film's last 3 s never count |
`keepsMoving` uses the same motion classifier as the frozen-sweep guard: box
geometry, opacity, text and generated content, form-control state, painted CSS
@@ -989,6 +1012,7 @@ npx hyperframes snapshot my-project --at 2.9,10.4,18.7
npx hyperframes snapshot my-project --frames 10
npx hyperframes snapshot my-project --zoom '#headline'
npx hyperframes snapshot my-project --at 1.5,4.3 --against ref.mp4
npx hyperframes snapshot my-project --review --json
```
```
@@ -1013,13 +1037,15 @@ npx hyperframes snapshot my-project --at 1.5,4.3 --against ref.mp4
| `--against` | A reference video, such as footage you are rebuilding. For every captured time also saves its frame-exact frame (`ref-XX-…png`) and a labelled render / reference pair (`pair-XX-…jpg`). |
| `--zoom-scale` | Pixel density for `--zoom` crops (default 3) |
| `--angle` | Orthogonal 3D camera for depth and occlusion checks: `front`, `iso`, `top`, `side`, or `yaw,pitch` in degrees. Tilts the whole stage, so you get real pixels. |
| `--review` | Capture the frames that show the film over time instead of evenly spaced ones: `review-beats.jpg` (each scene at its start + 0.3 s and at its middle, and each timed `data-beat="name"` element at its start + 0.3 s), `review-seam-NN.jpg` (−0.2 to +0.2 s around every cut) and `review-phone.jpg` (the beat frames 360 px wide). Scenes are the top-level timed clips and sub-composition hosts; `--composition` scopes to one. |
| `--json` | Print one envelope: `frames` with path, time and (under `--review`) role and label, the contact sheets, and under `--review` the scenes, beats and seams behind them. Progress goes to stderr. |
| `--describe` | Gemini vision analysis of each frame. Runs by default when `GEMINI_API_KEY` is set. Pass a question to override the prompt, or `--describe false` to opt out. |
| `--timeout` | Ms to wait for the runtime (default 5000) |
| `--browser-gpu`, `--proxy` | Same meaning as on [`check`](#check) |
Each snapshot is a 1920×1080 PNG taken by bundling the project, serving it,
launching headless Chrome, and seeking. Useful for visual verification during
the [product launch video](/guides/product-launch-video) workflow.
launching headless Chrome, and seeking. Useful for visual verification while
an agent builds, for example a [product or website video](/guides/product-launch-video).
### `keyframes`
@@ -1651,13 +1677,13 @@ npx hyperframes skills check --json
# Refresh the core set and everything already installed
npx hyperframes skills update
# Refresh, and also install one workflow on demand
npx hyperframes skills update pr-to-video
# Refresh, and also install one skill on demand
npx hyperframes skills update slideshow
```
Bare `skills` installs everything. `skills update` keeps a deliberate partial
installation partial: it refreshes the core set plus whatever is already
installed, and never expands beyond that unless you name a workflow. Naming one
installed, and never expands beyond that unless you name a skill. Naming one
adds it. `check` and `update` both accept `--json`, plus `--dir` and `--source`
to point removed-detection at a different skills directory or source — those two
scope the prune, not the install.
+5 -5
View File
@@ -5,12 +5,12 @@ description: "The six-part skeleton — route, spec, beats, copy, technique, neg
import { DocsVideo } from "/snippets/docs-video.jsx";
Your Level 1 prompts worked because the workflow filled the gaps for you — the palette, the pacing, the structure. This is Level 2: the skeleton is how you take that control yourself.
Your Level 1 prompts worked because the agent filled the gaps for you — the palette, the pacing, the structure. This is Level 2: the skeleton is how you take that control yourself.
The [copy-paste examples](/prompting/examples) share one skeleton. Each part removes a decision agents most often get wrong on a first try:
```text
[route] /motion-graphics
[route] /hyperframes
[spec] 8-second 1920x1080 video.
[beats] Beat 1 (0-4s): ... Beat 2 (4-5s): ... Beat 3 (5-8s): ...
[copy] the exact on-screen text, quoted
@@ -18,7 +18,7 @@ The [copy-paste examples](/prompting/examples) share one skeleton. Each part rem
[negatives] No narration, no image or media files.
```
- **Route** with a slash command — it loads the right workflow and the framework rules.
- **Route** with a slash command — `/hyperframes` loads the framework rules (`/slideshow` for a deck).
- **Spec** duration and resolution up front. Defaults are 1920x1080 at 30fps.
- **Beats** get timestamps. Include pacing instructions ("then hold on the blinking cursor") — agents skip breathing room unless told.
- **Copy** is quoted exactly, with `/` for line breaks. Unquoted copy gets paraphrased.
@@ -27,7 +27,7 @@ The [copy-paste examples](/prompting/examples) share one skeleton. Each part rem
Assembled:
> /motion-graphics Make an 8-second 1920x1080 video. Beat 1 (0-4s): dark macOS terminal types "npx skills add heygen-com/hyperframes" character by character, then hold on the blinking cursor. Beat 2 (4-5s): the terminal shatters into fragments. Beat 3 (5-8s): bold white kinetic text on black slams in word by word, snappy: "YOU JUST MADE THIS / WITH HYPERFRAMES." Adapt the `code-typing` and `vfx-shatter` registry blocks; hand-author the kinetic text. No narration, no image or media files.
> /hyperframes Make an 8-second 1920x1080 video. Beat 1 (0-4s): dark macOS terminal types "npx skills add heygen-com/hyperframes" character by character, then hold on the blinking cursor. Beat 2 (4-5s): the terminal shatters into fragments. Beat 3 (5-8s): bold white kinetic text on black slams in word by word, snappy: "YOU JUST MADE THIS / WITH HYPERFRAMES." Adapt the `code-typing` and `vfx-shatter` registry blocks; hand-author the kinetic text. No narration, no image or media files.
<DocsVideo
title="HyperFrames video: Validate Anatomy Terminal"
@@ -98,7 +98,7 @@ The skeleton above is how you ask. This is what the agent writes, and it's worth
| Footage with sound | `<video playsinline data-has-audio="true">` — the sound stays on the clip |
| Music / voiceover | separate `<audio>` |
You rarely write these by hand, but naming them in a prompt is legitimate and sometimes necessary — "put the captions on their own track index," "keep it one composition file, one timeline" — and it's the vocabulary every error message uses. The full contract lives in the `/hyperframes-core` skill; the [appendix](/prompting/rules-and-anti-patterns) carries the rules that most often bite.
You rarely write these by hand, but naming them in a prompt is legitimate and sometimes necessary — "put the captions on their own track index," "keep it one composition file, one timeline" — and it's the vocabulary every error message uses. The full contract lives in the `/hyperframes` skill and its `references/format.md`; the [appendix](/prompting/rules-and-anti-patterns) carries the rules that most often bite.
<Note>
**Capstone thread** — the [Level 7 film](/prompting/capstone) makes this table its scenery. In the Mount region the timeline wire splits into three track lanes and clip chips seat onto them, tagging themselves with the very attributes above — the framework's own vocabulary, staged as a set. And the whole prompt is this chapter's skeleton at maximum scale: route, spec, beats (its nine regions), copy quoted exactly, technique pinned per region, and a hard negative list.
+1 -1
View File
@@ -50,7 +50,7 @@ Three clauses were added after the fact. Frame review caught the builder driftin
That is the same catch-and-tighten loop [Iterating](/prompting/iterating) teaches, applied to the prompt itself so the next run cannot regress.
> /general-video Make a ~60-second 1920x1080 promo film for HyperFrames — the open-source framework that turns hand-authored HTML and GSAP animation into deterministic, frame-accurate video. The film's concept IS the product: the viewer travels along one literal timeline — the thing HyperFrames is built on — from the first typed character to the finished MP4. Everything true, nothing invented: real catalog counts, real cloud regions, real product surfaces.
> /hyperframes Make a ~60-second 1920x1080 promo film for HyperFrames — the open-source framework that turns hand-authored HTML and GSAP animation into deterministic, frame-accurate video. The film's concept IS the product: the viewer travels along one literal timeline — the thing HyperFrames is built on — from the first typed character to the finished MP4. Everything true, nothing invented: real catalog counts, real cloud regions, real product surfaces.
>
> **Design system: the provided `frame.md` is the single source of brand truth — read it first and obey it exactly.** It is the real HyperFrames brand (by HeyGen), dark-first: flat near-black ground (`--page-bg` `#0a0a0a`), greyscale text hierarchy (`#fafafa` / `#a1a1aa` / `#71717a`), and **mint `#3CE6AC` used sparingly as the single accent — mint means go / primary / active**. The mint→cyan gradient (`#3CE6AC → #00E3FF`) appears in exactly one place in the whole film: the final CTA lockup. Typography from the file: **ABC Solar Display 700** for the display lockups ("WRITE HTML." / "WRITE HTML. RENDER VIDEO."), **TT Norms Pro** for everything else, **TT Norms Pro Mono** for all machine-flavored text (code, commands, timecodes, counts, region labels) with `tabular-nums`. Sentence case everywhere except small uppercase letter-spaced eyebrows. Glass surfaces use the file's `--glass-*` tokens with real backdrop blur. Radii, borders, shadows, and easing curves come from the file's tokens (`--ease-out-quint` entrances, `--ease-spring` count-bumps). Font files are provided as woff2 assets — bundle them, don't fetch from the network.
>
+24 -28
View File
@@ -9,11 +9,9 @@ The first two rides built a video from nothing and the third built one from a di
## Your first win
One prompt to [`/embedded-captions`](/prompting/overview), pointed at an existing clip, is enough for a finished captioned video — no technique required yet.
One `/hyperframes` prompt, pointed at an existing clip, is enough for a finished captioned video — no technique required yet.
Captions route by **identity**, not by mode. You pick one look from the catalog; the engine behind it is a lookup detail you never have to name. The default is a clean verbatim rail — `anchor` — with the occasional peak word composited behind the subject.
> /embedded-captions Add captions to ./interview.mp4. Use the `anchor` identity — clean verbatim rail carrying the spoken words, readable lower-third. Promote the single hardest-hitting word to an embed behind the speaker; highlight one key word in each rail line. Keep the source aspect ratio. Footage stays untouched.
> /hyperframes Add captions to ./interview.mp4. Clean verbatim rail carrying the spoken words, readable lower-third. Promote the single hardest-hitting word to an embed behind the speaker; highlight one key word in each rail line. Keep the source aspect ratio. Footage stays untouched.
<DocsVideo
title="HyperFrames video: Captions Anchor Rail"
@@ -23,36 +21,34 @@ Captions route by **identity**, not by mode. You pick one look from the catalog;
*Rendered from the prompt above on generated avatar footage, unedited — one earned embed behind the speaker, everything else on the rail.*
The rail carries most of the text; an **embed** is the scarce, earned peak — one big word matted behind the subject at the climax, never every line. Embedding the whole transcript is the most common mistake this skill guards against.
The rail carries most of the text; an **embed** is the scarce, earned peak — one big word matted behind the subject at the climax, never every line. Embedding the whole transcript is the most common mistake.
## Two things you can add to a talking head
Both workflows take an existing talking-head / interview / podcast clip and add a layer on top. Neither edits the footage — no trims, no recolor, no reframe, no reorder. The clip plays untouched underneath; you're choosing what rides on top of it.
Both take an existing talking-head / interview / podcast clip and add a layer on top. Neither edits the footage — no trims, no recolor, no reframe, no reorder. The clip plays untouched underneath; you're choosing what rides on top of it.
| You want | Route | What it adds |
| You want | Ask for | What it adds |
| --- | --- | --- |
| The spoken words as readable text | `/embedded-captions` | Captions / subtitles — a rail, with earned climax embeds |
| Designed on-screen graphics synced to the talk | `/talking-head-recut` | Overlay cards — titles, lower-thirds, data callouts, quotes, PiP |
| The spoken words as readable text | Captions | Captions / subtitles — a rail, with earned climax embeds |
| Designed on-screen graphics synced to the talk | Overlay cards | Titles, lower-thirds, data callouts, quotes, PiP |
If the words themselves need to read, you want captions. If you want a produced look — kinetic titles, a stat callout, a pull-quote card, the speaker shrunk into a corner while a chart fills the frame — you want overlay cards. When it's genuinely both, caption first, then package; they're siblings, not substitutes.
`/embedded-captions` runs locally end to end — it transcribes and mattes the subject itself, no API key — and needs a **single-subject clip**. Multi-speaker clips or hard cuts get split per shot or refused, because the matte is one person.
## Base prompt — overlay cards
> /talking-head-recut Package ./founder-clip.mp4 with designed graphic overlay cards synced to the transcript. 9:16 portrait, warm-paper style. Open with a fullscreen kicker + title hook, drop a lower-third when she names the company, a data callout card counting up the "200+ teams / $1.2M ARR" stat, and a pull-quote card for the strongest line. Speaker stays full-bleed under the cards; shrink her into a corner PiP while the data card holds. The clip plays untouched underneath.
> /hyperframes Package ./founder-clip.mp4 with designed graphic overlay cards synced to the transcript. 9:16 portrait, warm-paper style. Open with a fullscreen kicker + title hook, drop a lower-third when she names the company, a data callout card counting up the "200+ teams / $1.2M ARR" stat, and a pull-quote card for the strongest line. Speaker stays full-bleed under the cards; shrink her into a corner PiP while the data card holds. The clip plays untouched underneath.
You describe the *cards* — their content, timing, and how the speaker shares the canvas with them (full-bleed, split, PiP, or glass overlay). The skill designs and writes each card; there's no fixed archetype list, so the overlays follow what the transcript actually says.
You describe the *cards* — their content, timing, and how the speaker shares the canvas with them (full-bleed, split, PiP, or glass overlay). The agent designs and writes each card; there's no fixed archetype list, so the overlays follow what the transcript actually says.
## The knobs that matter
What you can already steer from the prompt, before you've learned any technique.
**Identity and tone (captions).** One identity picks the entire look — surface, palette, motion, climax behavior. Route by content: explainer / interview / must-read words → a rail-carrying identity, with `anchor` the conservative default where every word has to read; poetic / social / cinematic → a column-flow identity by register (`editorial`, `cream`, `loud`, `neon`); "炸 / 特效 / VFX" → a themed identity (`ordnance`, `terminal`, `stomp`). Unsure → `anchor`: the words read and the scene stays safe. Don't ask for "Standard vs Cinematic vs Theme" — those are engine names; name the identity.
**Look and tone (captions).** Describe the look by content: explainer / interview / must-read words → a clean readable rail; poetic / social / cinematic → hero typography behind the speaker; hype → bolder, punchier type. Unsure → a clean rail: the words read and the scene stays safe. Ready-made caption styles are in the [captions catalog](/prompting/captions-catalog).
**Verbatim rail vs climax embed.** The rail is the default and carries most of the text. An embed is a promotion — one peak word matted behind the subject, scarce and spaced (roughly one per beat, never two co-visible, at most one apex). Tell the skill *which* lines earn the embed; leave the rest on the rail.
**Verbatim rail vs climax embed.** The rail is the default and carries most of the text. An embed is a promotion — one peak word matted behind the subject, scarce and spaced (roughly one per beat, never two co-visible, at most one apex). Tell the agent *which* lines earn the embed; leave the rest on the rail.
**Style, layout, and canvas (recut).** Pick a style group (warm-paper / clinical / experimental), a layout (split / stack / pip / overlay), and a canvas ratio; the video frame follows from layout × style. The recommended ratio matches the source, but you choose — 16:9 for desktop / YouTube, 9:16 for Reels / Shorts, 4:5 for feed.
**Style, layout, and canvas (overlay cards).** Name a style (warm paper, clinical, …), a layout (split / stack / PiP / overlay), and a canvas ratio. Matching the source ratio is the safe default, but you choose — 16:9 for desktop / YouTube, 9:16 for Reels / Shorts, 4:5 for feed.
**Keyword highlighting.** On the caption rail, a punch word can carry an inline `emphasis` — an accent-color or active-word pop — without leaving the rail. Ask for "highlight the key word in each line" and it stays readable.
@@ -60,30 +56,30 @@ What you can already steer from the prompt, before you've learned any technique.
<AccordionGroup>
<Accordion title="Cinematic caption embed (mood over verbatim)">
> /embedded-captions Cinematic captions on ./poem.mp4 — no rail, hero typography composited behind the speaker, words accumulating as a column. Use the `editorial` identity (lowercase-italic hero). One apex word per thought, air between them. 4:5. Never grade the footage.
> /hyperframes Cinematic captions on ./poem.mp4 — no rail, lowercase-italic hero typography composited behind the speaker, words accumulating as a column. One apex word per thought, air between them. 4:5. Never grade the footage.
Column-flow identities drop the rail and make everything embed-style — reach for them on poetic / social / "cinematic" asks where mood beats strict readability, never on an explainer where the words must read.
Dropping the rail and setting everything behind the speaker suits poetic / social / "cinematic" asks where mood beats strict readability, never on an explainer where the words must read.
</Accordion>
<Accordion title="Bright-scene captions">
> /embedded-captions Add verbatim captions to ./outdoor-vlog.mp4. It's a bright daylight scene, so use the `ink` identity — near-black type printed onto the surface — not a light-on-bright look that washes out. Keep it a readable rail. 16:9.
> /hyperframes Add verbatim captions to ./outdoor-vlog.mp4. It's a bright daylight scene, so use near-black type printed onto the surface — not a light-on-bright look that washes out. Keep it a readable rail. 16:9.
Screen-blend cream looks wash out over bright backdrops (luminance > ~180); `ink` is built for bright surfaces. Match the identity to the scene rather than asking the engine to recolor a look.
Light, screen-blended type washes out over bright backdrops (brightness above ~180); dark, opaque type holds. Match the look to the scene.
</Accordion>
<Accordion title="VFX-grade themed captions">
> /embedded-captions Bring the energy on ./hype-clip.mp4 — I want the captions to hit hard. Use the `ordnance` identity: a stamped verbatim rail with a detonation apex. Rail carries the verbatim; the payoff line is the setpiece. 9:16.
> /hyperframes Bring the energy on ./hype-clip.mp4 — I want the captions to hit hard: a stamped verbatim rail with a detonation apex. Rail carries the verbatim; the payoff line is the setpiece. 9:16.
Themed identities (`ordnance`, `terminal`, `stomp`, `neonsign`, `stardust`, …) are the answer to "make it explode / 特效 / like AE did it". Theme mode is the one place a register-gated reaction beat may touch the frame — applied after the matte composite so subject, text, and plate move as one — but the a-roll is still never graded.
Name the treatment — stamped type, a detonation on the payoff line — instead of "make it explode". The a-roll is still never graded.
</Accordion>
<Accordion title="Landscape data recut">
> /talking-head-recut Recut ./analyst-interview.mp4 as a 16:9 explainer with a clinical style. Split layout: speaker on the right, data cards on the left. Cards for each claim — a count-up for the headline number, a swiss-grid comparison for the two options, a terminal-style callout for the technical bit. Auto-pace the card count for a 5-minute clip. Footage untouched.
> /hyperframes Recut ./analyst-interview.mp4 as a 16:9 explainer with a clinical style. Split layout: speaker on the right, data cards on the left. Cards for each claim — a count-up for the headline number, a swiss-grid comparison for the two options, a terminal-style callout for the technical bit. Auto-pace the card count for a 5-minute clip. Footage untouched.
Layout (split / stack / pip / overlay) sets how speaker and cards share the canvas; card count auto-infers from duration and information density, with a floor of five so even a short clip has rhythm.
Layout (split / stack / pip / overlay) sets how speaker and cards share the canvas; left unstated, card density follows the clip's length — roughly one card every 12–20 seconds on a 5-minute clip.
</Accordion>
</AccordionGroup>
## Failure modes
**Asking for footage edits.** Both skills add a layer and leave the a-roll exactly as shot. Trimming, speeding up, recoloring, reframing, or reordering is NLE editing and out of scope — captions and cards are the only additions.
**Mixing footage edits into a caption pass.** Captions and cards add a layer and leave the a-roll exactly as shot. Trimming, speeding up, recoloring, reframing, or reordering is a different job — ask for it separately.
- ❌ `add captions and trim the dead air at the start, and warm up the color to match my brand`
- ✅ `add captions; leave the footage untouched` — do the trim / grade in an editor first, then bring the finished clip here.
@@ -91,14 +87,14 @@ What you can already steer from the prompt, before you've learned any technique.
- ❌ `composite every caption behind the speaker for a cinematic look` (on an explainer)
- ✅ `verbatim rail; promote only the two payoff lines to an embed`
**Multi-subject clips.** The caption matte is one person; two speakers or hard cuts flicker or get refused.
**Multi-subject clips.** Text behind the speaker is matted against one person; two speakers or hard cuts make it flicker.
- ❌ `caption this two-person podcast in one pass`
- ✅ `split the clip per shot / per speaker first, then caption each` — or use a single-subject cut.
<Tip>
For the beat-timestamped skeleton these prompts share, see [Prompt anatomy](/prompting/anatomy); for adjectives that map to motion and emphasis settings, [Vocabulary](/prompting/vocabulary). To build a video from scratch instead of dressing existing footage, start at the router in `/hyperframes`.
For the beat-timestamped skeleton these prompts share, see [Prompt anatomy](/prompting/anatomy); for adjectives that map to motion and emphasis settings, [Vocabulary](/prompting/vocabulary). To build a video from scratch instead of dressing existing footage, ask `/hyperframes` for it directly.
</Tip>
The workflow this level rides is documented at [Captions and talking head](/guides/captions-and-recuts) — what it takes as input, what it asks you before it builds, and what it returns.
The matching guide is [Captions and talking head](/guides/captions-and-recuts) — what to give the agent, what it confirms before building, and what it returns.
*Next: [Music videos and slideshows](/prompting/music-and-slideshows) — swap footage for a soundtrack, or slides, and let the beat or the deck set the pace.*
+3 -3
View File
@@ -12,7 +12,7 @@ Your faceless explainer from Level 1 already asked for "embedded captions, keywo
Caption components are drop-in snippets that render animated on-screen text — one visual identity per component, animating per word or per line. Prompts trigger this layer when you ask for captions, subtitles, kinetic text, lyric-style words, or word-by-word titles inside a composition you're building. Describe the *energy* of the captions and the agent picks matching typography, size, and animation; name a component to lock the look.
<Note>
These components are for **composed videos** — captions you author into a HyperFrames composition. To add captions to an existing **talking-head MP4**, use the [`/embedded-captions`](/prompting/captions-and-talking-heads) workflow instead: it carries its own catalog of caption identities built around subject matting and occlusion (the caption sits *behind* the speaker), which the composition snippets below don't do.
These components are for **composed videos** — captions you author into a HyperFrames composition. To add captions to an existing **talking-head MP4**, see [captions and talking heads](/prompting/captions-and-talking-heads) instead: captions set *behind* the speaker need a cutout of the subject layered over them, which the composition snippets below don't do.
</Note>
## Tone → caption component
@@ -40,7 +40,7 @@ Three [Text Effects](/catalog/components/morph-text) components do one focused j
## Example prompts
> /faceless-explainer 30-second vertical explainer. Add [`caption-highlight`](/catalog/components/caption-highlight) captions, TikTok-style — the visible line stays up, one word highlighted at a time.
> /hyperframes 30-second vertical explainer. Add [`caption-highlight`](/catalog/components/caption-highlight) captions, TikTok-style — the visible line stays up, one word highlighted at a time.
<DocsVideo
title="HyperFrames video: Validate Captions Catalog"
@@ -97,7 +97,7 @@ Three [Text Effects](/catalog/components/morph-text) components do one focused j
**Don't reach for these on talking-head footage.** These are composition snippets, not the matting/occlusion pipeline — dropped onto an untouched MP4, a caption sits in front of the speaker, never behind. (The capstone thread below shows `caption-kinetic-slam` reading *behind* a subject, which is not a contradiction: that composition mattes the footage itself first, so the cutout is a separate layer the type can pass under. The limitation is about the snippet alone, not the technique.)
- ❌ `/hyperframes add caption-highlight to my interview.mp4`
- ✅ `/embedded-captions` (see [captions and talking heads](/prompting/captions-and-talking-heads))
- ✅ `/hyperframes caption interview.mp4 with the words set behind the speaker` (see [captions and talking heads](/prompting/captions-and-talking-heads))
**Don't match a hype style to calm content.** A high-energy caption on a corporate explainer fights the tone; let the tone table pick the identity.
- ❌ `glitchy RGB captions` (on a wellness brand piece)
+10 -18
View File
@@ -9,11 +9,11 @@ Two rides in, both built from nothing yet on the page. This one starts from some
## Your first win
One prompt to [`/pr-to-video`](/prompting/overview), pointed at a PR link, is enough for a finished code-change explainer — no technique required yet.
One `/hyperframes` prompt, pointed at a PR link, is enough for a finished code-change explainer — no technique required yet.
Verified, from the [examples](/prompting/examples) page — a 30-second feature reveal:
> /pr-to-video Make a 30-second 1920x1080 feature-reveal video from [PR URL]. Lead with what users get, not the diff; show the key code change with the `code-diff` block for one beat only; end on version number + repo URL. No narration, kinetic captions instead.
> /hyperframes Make a 30-second 1920x1080 feature-reveal video from [PR URL]. Lead with what users get, not the diff; show the key code change with the `code-diff` block for one beat only; end on version number + repo URL. No narration, kinetic captions instead.
<DocsVideo
title="HyperFrames video: Example Pr Video"
@@ -25,9 +25,9 @@ Verified, from the [examples](/prompting/examples) page — a 30-second feature
## What this makes
A code-change explainer built from a GitHub pull request. The [`/pr-to-video`](/prompting/overview) workflow reads the PR through `gh` — the diff, commits, files, and contributors — reshapes it into a story, and builds it frame by frame, rendering code beats on a purpose-built diff surface.
A code-change explainer built from a GitHub pull request. Given a PR, the agent reads it through `gh` — the diff, commits, files, and contributors — reshapes it into a story, and builds it scene by scene, rendering code beats with the registry's `code-*` blocks.
The input is a **code change**, not a website or a product page. A PR link (`https://github.com/owner/repo/pull/N`), an `owner/repo#N` ref, or "this PR" in a checked-out repo all work. A product to sell → [`/product-launch-video`](/prompting/product-launch); a topic with no PR → [`/faceless-explainer`](/prompting/explainers). Unsure → start at `/hyperframes`.
The input is a **code change**, not a website or a product page. A PR link (`https://github.com/owner/repo/pull/N`), an `owner/repo#N` ref, or "this PR" in a checked-out repo all work. A product to sell → [product launch](/prompting/product-launch); a topic with no PR → [explainers](/prompting/explainers).
## The knobs that matter
@@ -38,21 +38,17 @@ What you can already steer from the prompt, before you've learned any technique.
| **Impact vs diff** | "lead with what users get, not the diff" | The video explains the *change*, it doesn't read the diff aloud; opening on impact answers "why should I care?" before the code |
| **How many hunks** | "one `code-diff` beat only" / "at most two hunks" | Code beats feature 2-4 real hunks total, each a small legible snippet — a whole file is unreadable at video scale |
| **Which code block** | `code-diff` for a delta · `code-morph` for a refactor · `code-typing` for new code | The block matches the story: a diff shows added/removed lines, a morph shows one shape becoming another |
| **Angle** | "changelog" / "feature-reveal" / "fix-explainer" / "refactor-walkthrough" | Sets the story shape; the workflow reshapes the PR into it rather than walking files in diff order |
| **Angle** | "changelog" / "feature-reveal" / "fix-explainer" / "refactor-walkthrough" | Sets the story shape; the agent reshapes the PR into it rather than walking files in diff order |
| **Audience** | "for developers" (default) / "mixed technical" / "non-technical stakeholders" | Shifts how much the narration assumes — a non-technical cut leans harder on impact and lighter on code |
| **End card** | "end on version number + repo URL" | The conventional close for a code explainer; state what goes on it so it's a real CTA, not an afterthought |
| **Narration vs captions** | "calm male narration" or "no narration, kinetic captions instead" | Both are supported; captions-only keeps it silent-friendly for social, narration carries a longer walkthrough |
| **Length** | "~30s" for one headline, up to ~3 min for a large PR | The workflow reads the change size and recommends a tier — a huge PR is a ceiling on story, not a floor to fill |
<Tip>
The style is fixed to the workflow's warm-editorial preset with a navy code surface built for diffs — it's what makes the code beats legible. You don't choose a theme here; you choose the angle, the hunks, and the narration.
</Tip>
| **Length** | "~30s" for one headline, up to ~3 min for a large PR | The agent sizes the video from the change — a huge PR is a ceiling on story, not a floor to fill |
## Variants
<AccordionGroup>
<Accordion title="Changelog roundup">
> /pr-to-video Make a ~40-second 1920x1080 changelog video from [PR URL]. Changelog angle: open with the release line, then one beat per notable change — a short label and a one-line "what it does" each. Show at most two `code-diff` hunks across the whole video. End on version + repo URL. Calm male TTS narration, no captions.
> /hyperframes Make a ~40-second 1920x1080 changelog video from [PR URL]. Changelog angle: open with the release line, then one beat per notable change — a short label and a one-line "what it does" each. Show at most two `code-diff` hunks across the whole video. End on version + repo URL. Calm male TTS narration, no captions.
<DocsVideo
title="HyperFrames video: Variant Pr Changelog"
@@ -64,12 +60,12 @@ What you can already steer from the prompt, before you've learned any technique.
A changelog trades depth for breadth — many small changes, each a beat, rather than one change explored deeply. Keep code beats sparse so the pace stays fast. A PR doesn't carry its shipping version — the agent finds it from the release tag or the package manifest on main.
</Accordion>
<Accordion title="Fix explainer">
> /pr-to-video Make a ~40-second 1920x1080 fix-explainer from [PR URL], for developers. Fix angle: state the bug's symptom first, then the root cause, then the one-line fix on the `code-diff` block. End on version + repo URL. No narration, kinetic captions.
> /hyperframes Make a ~40-second 1920x1080 fix-explainer from [PR URL], for developers. Fix angle: state the bug's symptom first, then the root cause, then the one-line fix on the `code-diff` block. End on version + repo URL. No narration, kinetic captions.
A fix reads as symptom → cause → fix. Lead with what users saw break, not the stack trace — the diff is the payoff, not the opening.
</Accordion>
<Accordion title="Refactor walkthrough">
> /pr-to-video Make a ~70-second 1920x1080 refactor-walkthrough from [PR URL], for developers. Refactor angle: why the old shape hurt, then the new shape, showing the before/after with the `code-morph` block for the key file. End on version + repo URL. Calm male TTS narration.
> /hyperframes Make a ~70-second 1920x1080 refactor-walkthrough from [PR URL], for developers. Refactor angle: why the old shape hurt, then the new shape, showing the before/after with the `code-morph` block for the key file. End on version + repo URL. Calm male TTS narration.
A refactor changes shape without changing behavior, so the story is *why* the new structure is better. `code-morph` animates one form transforming into another — the right block when the point is the transition, not a line-by-line delta.
</Accordion>
@@ -77,10 +73,6 @@ What you can already steer from the prompt, before you've learned any technique.
## Common failure modes
**Forcing a theme over the preset.** The general rule is on [ride 1](/prompting/product-launch#common-failure-modes); the specific reason here is that the navy code surface is tuned for diff legibility, so a foreign theme costs you the one thing the workflow is good at.
- ❌ `/pr-to-video ... dark theme, neon accents`
- ✅ let the preset carry the look; spend your specificity on the angle and the code beats
**Asking for the whole diff.** A PR video explains the change; it doesn't recite every file. A full diff is unreadable at video scale.
- ❌ `walk through the entire diff, file by file`
- ✅ `feature the 2-3 key hunks, each a small legible snippet`
@@ -93,6 +85,6 @@ What you can already steer from the prompt, before you've learned any technique.
- ❌ `a 40-second narrated walkthrough`
- ✅ `a ~40-second narrated walkthrough`
The workflow this level rides is documented at [PR to video](/guides/pr-to-video) — what it takes as input, what it asks you before it builds, and what it returns.
The matching guide is [PR to video](/guides/pr-to-video) — what to give the agent, what it confirms before building, and what it returns.
*Next: [Captions and talking-head footage](/prompting/captions-and-talking-heads) — same one-prompt move, now dressing footage you already shot.*
+5 -5
View File
@@ -36,7 +36,7 @@ The first four are the workhorses of a code *walkthrough* — they keep the code
`code-typing` reveals code character by character with a caret that tracks the frontier — no CSS animation, so it seeks cleanly. Give it the exact code and a pace; the agent re-bakes the block's syntax tokens to your snippet.
> /motion-graphics 6-second 1920x1080 video. A dark editor types this snippet, character by character, caret tracking the frontier, then holds on the blinking cursor for the final second:
> /hyperframes 6-second 1920x1080 video. A dark editor types this snippet, character by character, caret tracking the frontier, then holds on the blinking cursor for the final second:
> ```
> export async function render(comp: Composition) {
> await comp.seek(0);
@@ -65,7 +65,7 @@ The first four are the workhorses of a code *walkthrough* — they keep the code
For "here's what changed," hand `code-diff` the before and after and let it color the delta. For "look at *this* line," give `code-highlight` the full context and name the target line.
> /motion-graphics 6-second 1920x1080 video. Show this edit to `api.ts` as a colored diff — the removed line collapses in red, the added line expands in green:
> /hyperframes 6-second 1920x1080 video. Show this edit to `api.ts` as a colored diff — the removed line collapses in red, the added line expands in green:
> removed: `const res = await fetch(url)`
> added: `const res = await fetch(url, { signal })`
> Use the `code-diff` registry block. No audio.
@@ -78,7 +78,7 @@ For "here's what changed," hand `code-diff` the before and after and let it colo
*Rendered from the prompt above, unedited.*
> /motion-graphics 5-second 1920x1080 video. Show a 12-line config file; a highlight band sweeps to line 7 (`timeout: 30_000`) while the surrounding lines dim. Hold with line 7 lit and the cursor blinking. Use the `code-highlight` registry block. No audio.
> /hyperframes 5-second 1920x1080 video. Show a 12-line config file; a highlight band sweeps to line 7 (`timeout: 30_000`) while the surrounding lines dim. Hold with line 7 lit and the cursor blinking. Use the `code-highlight` registry block. No audio.
<DocsVideo
title="HyperFrames video: Validate Code Highlight"
@@ -96,7 +96,7 @@ For "here's what changed," hand `code-diff` the before and after and let it colo
`code-scroll` moves the camera down a long file to bring a target line to center and spotlights it — the block for walking real modules, not toy snippets.
> /motion-graphics 6-second 1920x1080 video. Scroll a ~60-line source file so line 44 (`return dedupeFrames(frames)`) arrives at center and gets spotlighted; ease the scroll and let it settle without snapping. Use the `code-scroll` registry block. No audio.
> /hyperframes 6-second 1920x1080 video. Scroll a ~60-line source file so line 44 (`return dedupeFrames(frames)`) arrives at center and gets spotlighted; ease the scroll and let it settle without snapping. Use the `code-scroll` registry block. No audio.
<DocsVideo
title="HyperFrames video: Code Scroll"
@@ -140,7 +140,7 @@ For "here's what changed," hand `code-diff` the before and after and let it colo
### Pairing with a pull request
When the code you're animating comes from a real PR, don't hand-write the beats — the [`/pr-to-video`](/prompting/code-and-prs) workflow reads the diff and composes `code-diff`, `code-highlight`, and `code-scroll` around the actual changed hunks. Use the blocks on this page directly when you're illustrating a concept; route through the PR workflow when you're narrating a specific change set.
When the code you're animating comes from a real PR, don't hand-write the beats — [point the agent at the PR](/prompting/code-and-prs) and it reads the diff and composes `code-diff`, `code-highlight`, and `code-scroll` around the actual changed hunks. Use the blocks on this page directly when you're illustrating a concept; hand over the PR when you're narrating a specific change set.
### Where to go next
+5 -5
View File
@@ -18,7 +18,7 @@ There are two ways to get a chart, and the choice is about control:
Feed data inline or as a file. Small series go straight in the prompt; a CSV gets referenced and parsed at build time (keep it deterministic — no [render-time fetches](/concepts/determinism)).
> /motion-graphics 12-second 1920x1080 video. Title "Most-used languages" with the subtitle "share of respondents, 2026 survey". Turn this into an animated bar chart with a staggered reveal and value labels counting up on each bar, each label suffixed `%`:
> /hyperframes 12-second 1920x1080 video. Title "Most-used languages" with the subtitle "share of respondents, 2026 survey". Turn this into an animated bar chart with a staggered reveal and value labels counting up on each bar, each label suffixed `%`:
> ```
> Python 41, TypeScript 33, Rust 19, Go 14, Java 9
> ```
@@ -31,7 +31,7 @@ Feed data inline or as a file. Small series go straight in the prompt; a CSV get
/>
*Rendered from the prompt above, unedited — labels count up while the next bar is already growing.*
> /motion-graphics 10-second 1920x1080 video, dark slate background. Title "Top languages 2026" top-left. Five horizontal bars (Python, TypeScript, Rust, Go, Java) grow from zero with staggered starts, overtaking each other twice mid-animation; each bar has a right-edge value label counting up to its final %. End state holds 2s, alive — the leader pulses once, timed to finish exactly on the last frame, never a frozen tail. Hand-author the marks (no chart library) and make them *look* hand-drawn: wobbly ink strokes, not clean rectangles. Say which two pairs overtake and when, or the reveal will land on a different number of crossings each run. No audio.
> /hyperframes 10-second 1920x1080 video, dark slate background. Title "Top languages 2026" top-left. Five horizontal bars (Python, TypeScript, Rust, Go, Java) grow from zero with staggered starts, overtaking each other twice mid-animation; each bar has a right-edge value label counting up to its final %. End state holds 2s, alive — the leader pulses once, timed to finish exactly on the last frame, never a frozen tail. Hand-author the marks (no chart library) and make them *look* hand-drawn: wobbly ink strokes, not clean rectangles. Say which two pairs overtake and when, or the reveal will land on a different number of crossings each run. No audio.
<DocsVideo
title="HyperFrames video: Barrace Handdrawn"
@@ -53,7 +53,7 @@ Feed data inline or as a file. Small series go straight in the prompt; a CSV get
For a single hero number, [`apple-money-count`](/catalog/blocks/apple-money-count) is the Apple-style finance counter — it rolls from $0, flashes green, and bursts money icons with sound. Name it when you want that exact flourish; hand-draw when you want a bare number in your own type.
> /motion-graphics 6-second 1920x1080 video, dark navy background. Beat 1 (0-1s): label "ARR" fades up small, top-center. Beat 2 (1-4s): a giant number counts up to $4.2M with an odometer roll, easing out as it lands. Beat 3 (4-6s): "+312% YoY" stamps in below in green, then settles into a gentle ambient idle. Use the `apple-money-count` registry block as base. No narration.
> /hyperframes 6-second 1920x1080 video, dark navy background. Beat 1 (0-1s): label "ARR" fades up small, top-center. Beat 2 (1-4s): a giant number counts up to $4.2M with an odometer roll, easing out as it lands. Beat 3 (4-6s): "+312% YoY" stamps in below in green, then settles into a gentle ambient idle. Use the `apple-money-count` registry block as base. No narration.
<DocsVideo
title="HyperFrames video: Money Count"
@@ -78,7 +78,7 @@ Each map block answers a different geographic question. Say what the map is *for
The US map encodings combine — asking to "shade states *and* draw flows between two cities" gets you one composition: the agent merges the blocks at source onto a shared projection so arcs land exactly on the choropleth's states.
> /motion-graphics 12-second 1920x1080 video. A US choropleth shades states by adoption rate with staggered reveals and a gradient legend, then connection arcs draw between San Francisco, Austin, and New York. Use the `us-map` and `us-map-flow` registry blocks. No audio.
> /hyperframes 12-second 1920x1080 video. A US choropleth shades states by adoption rate with staggered reveals and a gradient legend, then connection arcs draw between San Francisco, Austin, and New York. Use the `us-map` and `us-map-flow` registry blocks. No audio.
<DocsVideo
title="HyperFrames video: Validate Us Map Flows"
@@ -104,7 +104,7 @@ The US map encodings combine — asking to "shade states *and* draw flows betwee
For a point-to-point journey — a route drawing across a map with a landing beat — [`nyc-paris-flight`](/catalog/blocks/nyc-paris-flight) is the Apple-style flight animation: a plane flies New York → Paris with a marker circle, landing pop, and sound effects. Use it as the base and re-point the endpoints in your prompt.
> /motion-graphics 6-second 1920x1080 video. A realistic map with a plane flying between two cities, a marker circle at the origin, and a landing pop at the destination. Use the `nyc-paris-flight` registry block as base — the route runs New York (JFK) → Paris (CDG), labelled at both ends. Keep the block's own title card and route ticks. End on the settled map, not on the block's white-out hand-off frame. No audio.
> /hyperframes 6-second 1920x1080 video. A realistic map with a plane flying between two cities, a marker circle at the origin, and a landing pop at the destination. Use the `nyc-paris-flight` registry block as base — the route runs New York (JFK) → Paris (CDG), labelled at both ends. Keep the block's own title card and route ticks. End on the settled map, not on the block's white-out hand-off frame. No audio.
<DocsVideo
title="HyperFrames video: Flight Route"
+7 -7
View File
@@ -76,7 +76,7 @@ A series is a launch set, a weekly clip, or a per-region cut. Consistency comes
The constant parts come from that one authoring spec, or from one set of imported Figma tokens. The parts that vary become [variables](/prompting/variables-and-templating).
When `frame.md` changes, rerun the workflow steps that generate or assemble the affected compositions. That's how the authored HTML picks up the new brand value.
When `frame.md` changes, ask the agent to rebuild the affected compositions. That's how the authored HTML picks up the new brand value.
Some compositions already exist and have to re-skin at render time. Declare the shared brand tokens as composition variables instead. Every scalar variable is applied as a `--{id}` CSS custom property on the composition root, so `var(--id)` in your CSS follows the override.
@@ -92,16 +92,16 @@ Prefer an SVG logo over a raster one. It scales and it animates.
State the paths explicitly. That way the agent wires up the real assets instead of generating stand-ins. It also keeps the render deterministic, because every asset is present locally before the render starts.
## Supply inputs a workflow accepts — don't fight its preset
## Supply brand inputs up front — don't restyle afterward
Each creation workflow comes with a designed look — `/product-launch-video`, `/faceless-explainer`, and the rest. Feed that look your brand inputs. Don't override its composition after the fact.
Give the agent your palette, fonts, and logo (or a `frame.md`) before the build. Don't override the composition after the fact.
- ❌ `run /product-launch-video, then restyle every scene to my colors afterward`
- ✅ `run /product-launch-video with my palette, fonts, and logo as inputs up front`
- ❌ `make the launch video, then restyle every scene to my colors afterward`
- ✅ `make the launch video with my palette, fonts, and logo as inputs up front`
Why this works: a workflow's preset is a coherent, tested system. Its colors, spacing, motion, and component treatments hang together. Supply your brand inputs at the start and it applies your palette and type *within* that system.
Why this works: the agent settles the look before the first scene, so colors, spacing, motion, and component treatments hang together as one system.
Restyling scene by scene afterward pulls threads out of a design that was balanced as a whole. You spend more effort fighting the preset than handing it a spec would have cost.
Restyling scene by scene afterward pulls threads out of a design that was balanced as a whole. You spend more effort fighting it than handing over a spec would have cost.
## Related
+9 -9
View File
@@ -17,11 +17,11 @@ the preamble rather than in each one.
**The shared preamble — paste this above any prompt on this page.** It carries the [Level 3 motion grammar](/prompting/motion) so the individual prompts stay readable. Every one asks for the density contract (one focal element at display scale, supporting elements on their own cues, permanent chrome), three depth layers with parallax under one continuous non-settling camera, entrances staggered at offsets shorter than the animations they offset, overshoot on transforms only, and an ambient idle instead of a frozen final frame. What each prompt *does* state individually is its **spectacle beat** — the single exaggerated moment, placed where the piece earns it. That clause is load-bearing: an unnamed burst gets dropped, which is exactly what happened to the count-up's confetti before it was written down.
</Note>
### With registry blocks and workflows
### With registry blocks and source material
<AccordionGroup>
<Accordion title="Stat count-up">
> /motion-graphics 6-second 1920x1080 video, dark navy background. Beat 1 (0-1s): label "ARR" fades up small, top-center. Beat 2 (1-4s): a giant number counts up to $4.2M with an odometer roll, easing out as it lands. Beat 3 (4-6s): "+312% YoY" stamps in below in green, then everything settles into a gentle ambient idle (subtle breathing scale, slow particle drift). Use the `apple-money-count` registry block as base. No narration. **Spectacle beat:** On the land at 4s, a burst of ~60 paper money notes erupts from behind the numeral and flutters down, seeded so every render is identical, settled by 5.5s — the one exaggeration.
> /hyperframes 6-second 1920x1080 video, dark navy background. Beat 1 (0-1s): label "ARR" fades up small, top-center. Beat 2 (1-4s): a giant number counts up to $4.2M with an odometer roll, easing out as it lands. Beat 3 (4-6s): "+312% YoY" stamps in below in green, then everything settles into a gentle ambient idle (subtle breathing scale, slow particle drift). Use the `apple-money-count` registry block as base. No narration. **Spectacle beat:** On the land at 4s, a burst of ~60 paper money notes erupts from behind the numeral and flutters down, seeded so every render is identical, settled by 5.5s — the one exaggeration.
<DocsVideo
title="HyperFrames video: Example Stat Countup"
@@ -31,7 +31,7 @@ the preamble rather than in each one.
*Rendered from the prompt above, unedited.*
</Accordion>
<Accordion title="Animated social post">
> /motion-graphics 7-second 1080x1350 vertical video. A real tweet card (handle @hyperframes, text "we render video from HTML now. no timeline UI. just code.") slides up over a soft animated gradient, likes counter ticks 0→1.2K, then the card tilts in 3D and a highlight sweeps the second sentence. Hold on the card at the end with a subtle ambient idle (slow breathing scale). Use the `x-post` and `vfx-liquid-background` registry blocks. No narration, no image or media files. **Spectacle beat:** When the like count lands, the heart pops to 1.6× with a radial burst of ~24 seeded particles and settles — one moment, nothing else exaggerated.
> /hyperframes 7-second 1080x1350 vertical video. A real tweet card (handle @hyperframes, text "we render video from HTML now. no timeline UI. just code.") slides up over a soft animated gradient, likes counter ticks 0→1.2K, then the card tilts in 3D and a highlight sweeps the second sentence. Hold on the card at the end with a subtle ambient idle (slow breathing scale). Use the `x-post` and `vfx-liquid-background` registry blocks. No narration, no image or media files. **Spectacle beat:** When the like count lands, the heart pops to 1.6× with a radial burst of ~24 seeded particles and settles — one moment, nothing else exaggerated.
<DocsVideo
title="HyperFrames video: Example Tweet"
@@ -42,7 +42,7 @@ the preamble rather than in each one.
*Rendered from the prompt above, unedited.*
</Accordion>
<Accordion title="Map route">
> /motion-graphics 8-second 1920x1080 video. Dark world map, a glowing arc animates from San Francisco to Tokyo over 3s, destination pin drops with a pulse, then camera zooms into Tokyo and the label "LATENCY: 89ms" types on. Use the `nyc-paris-flight` registry block as the base pattern, restyle to teal on charcoal. No narration. **Spectacle beat:** The destination pin's landing fires a triple concentric shockwave that expands past the label and fades, with a brief chromatic split on the pin.
> /hyperframes 8-second 1920x1080 video. Dark world map, a glowing arc animates from San Francisco to Tokyo over 3s, destination pin drops with a pulse, then camera zooms into Tokyo and the label "LATENCY: 89ms" types on. Use the `nyc-paris-flight` registry block as the base pattern, restyle to teal on charcoal. No narration. **Spectacle beat:** The destination pin's landing fires a triple concentric shockwave that expands past the label and fades, with a brief chromatic split on the pin.
<DocsVideo
title="HyperFrames video: Example Map Route"
@@ -52,7 +52,7 @@ the preamble rather than in each one.
*Rendered from the prompt above, unedited.*
</Accordion>
<Accordion title="Logo sting with shader transition">
> /motion-graphics 5-second 1920x1080 logo sting. Beat 1 (0-2s): the word "ACME" assembles from scattered particles. Beat 2 (2-3s): full-frame `swirl-vortex` shader transition. Beat 3 (3-5s): logo lockup + tagline "Ship faster." settles on white and holds with a subtle ambient idle. Use `code-particle-assemble` for the assembly. **Spectacle beat:** The particle assembly IS the spectacle — ~1200 seeded particles converging with visible motion trails, and a single bloom flash on the frame the wordmark completes.
> /hyperframes 5-second 1920x1080 logo sting. Beat 1 (0-2s): the word "ACME" assembles from scattered particles. Beat 2 (2-3s): full-frame `swirl-vortex` shader transition. Beat 3 (3-5s): logo lockup + tagline "Ship faster." settles on white and holds with a subtle ambient idle. Use `code-particle-assemble` for the assembly. **Spectacle beat:** The particle assembly IS the spectacle — ~1200 seeded particles converging with visible motion trails, and a single bloom flash on the frame the wordmark completes.
<DocsVideo
title="HyperFrames video: Example Logo Sting"
@@ -62,7 +62,7 @@ the preamble rather than in each one.
*Rendered from the prompt above, unedited.*
</Accordion>
<Accordion title="Product launch from a URL">
> /product-launch-video Make a 45-second 1920x1080 launch video for https://linear.app. Energetic but minimal, use the site's own palette and screenshots. Structure: hook stating the problem, 3 feature beats with UI captures and one-line captions, end card with logo + "Try it free". Female TTS voice, confident tone, subtle electronic BGM under -18dB. **Spectacle beat:** One exaggerated moment: the end card's logo lands with a bloom flash and a fast light-sweep across the wordmark. Feature beats stay restrained.
> /hyperframes Make a 45-second 1920x1080 launch video for https://linear.app. Energetic but minimal, use the site's own palette and screenshots. Structure: hook stating the problem, 3 feature beats with UI captures and one-line captions, end card with logo + "Try it free". Female TTS voice, confident tone, subtle electronic BGM under -18dB. **Spectacle beat:** One exaggerated moment: the end card's logo lands with a bloom flash and a fast light-sweep across the wordmark. Feature beats stay restrained.
<DocsVideo
title="HyperFrames video: Example Product Launch"
@@ -72,7 +72,7 @@ the preamble rather than in each one.
*Rendered from the prompt above, unedited.*
</Accordion>
<Accordion title="Explainer from pasted text">
> /faceless-explainer Turn this into a ~60-second 1080x1920 vertical explainer: [paste your text]. One idea per scene, big typography, diagrams over stock footage, brand color #FF5533 on off-black. Male TTS voice, calm. Embedded captions, keywords highlighted in the brand color. **Spectacle beat:** One exaggerated moment: the final CTA's key phrase slams in at 1.5× with a chromatic split that resolves in 0.2s. Every other scene stays typographically calm.
> /hyperframes Turn this into a ~60-second 1080x1920 vertical explainer: [paste your text]. One idea per scene, big typography, diagrams over stock footage, brand color #FF5533 on off-black. Male TTS voice, calm. Embedded captions, keywords highlighted in the brand color. **Spectacle beat:** One exaggerated moment: the final CTA's key phrase slams in at 1.5× with a chromatic split that resolves in 0.2s. Every other scene stays typographically calm.
With a verbatim script, final duration follows the narration — ask for "~60 seconds", not exactly 60.
<DocsVideo
@@ -84,7 +84,7 @@ the preamble rather than in each one.
*Rendered from the prompt above, unedited.*
</Accordion>
<Accordion title="GitHub PR reveal">
> /pr-to-video Make a 30-second 1920x1080 feature-reveal video from [PR URL]. Lead with what users get, not the diff; show the key code change with the `code-diff` block for one beat only; end on version number + repo URL. No narration, kinetic captions instead. **Spectacle beat:** The added line in the diff ignites — a green light-sweep travels its length and the line blooms as the camera settles on it.
> /hyperframes Make a 30-second 1920x1080 feature-reveal video from [PR URL]. Lead with what users get, not the diff; show the key code change with the `code-diff` block for one beat only; end on version number + repo URL. No narration, kinetic captions instead. **Spectacle beat:** The added line in the diff ignites — a green light-sweep travels its length and the line blooms as the camera settles on it.
<DocsVideo
title="HyperFrames video: Example Pr Video"
@@ -94,7 +94,7 @@ the preamble rather than in each one.
*Rendered from the prompt above, unedited.*
</Accordion>
<Accordion title="Beat-synced slideshow">
> /music-to-video 20-second 1080x1080 video. Resolve a dark, driving electronic track and cut to its analyzed beat grid — one image per bar, punch-in on downbeats, whip-pan transitions on phrase changes. Generate the eight images rather than using stock: brutalist concrete details as high-contrast monochrome abstracts, one consistent visual language across all eight, each carrying a single cyan light thread. Chrome: a `PLATE 0N/08` counter. End card "CAST IN PLACE" in condensed caps over a hard-edged opaque scrim, with the sub-line "EIGHT SURFACES · ONE HUNDRED BPM". No TTS. **Spectacle beat:** On the loudest downbeat, one image punches to 1.25× with an RGB channel split that snaps back on the next beat. The other cuts stay clean.
> /hyperframes 20-second 1080x1080 video. Resolve a dark, driving electronic track and cut to its analyzed beat grid — one image per bar, punch-in on downbeats, whip-pan transitions on phrase changes. Generate the eight images rather than using stock: brutalist concrete details as high-contrast monochrome abstracts, one consistent visual language across all eight, each carrying a single cyan light thread. Chrome: a `PLATE 0N/08` counter. End card "CAST IN PLACE" in condensed caps over a hard-edged opaque scrim, with the sub-line "EIGHT SURFACES · ONE HUNDRED BPM". No TTS. **Spectacle beat:** On the loudest downbeat, one image punches to 1.25× with an RGB channel split that snaps back on the next beat. The other cuts stay clean.
<DocsVideo
title="HyperFrames video: Example Music Slideshow"
+25 -27
View File
@@ -5,19 +5,19 @@ description: "What to say to turn an article, notes, or a topic into a faceless
import { DocsVideo } from "/snippets/docs-video.jsx";
The last page pointed a workflow at a website. This one drops the site. Your
The last page pointed the agent at a website. This one drops the site. Your
text is the whole input. Every visual is invented, not captured.
## Your first win
Paste your text into one prompt to [`/faceless-explainer`](/prompting/overview).
Paste your text into one `/hyperframes` prompt.
That is enough for a finished explainer. No site, no footage, no design
decisions yet.
Verified, from the [examples](/prompting/examples) page — a ~60-second vertical
explainer from pasted text:
> /faceless-explainer Turn this into a ~60-second 1080x1920 vertical explainer: [paste your text]. One idea per scene, big typography, diagrams over stock footage, brand color #FF5533 on off-black. Male TTS voice, calm. Embedded captions, keywords highlighted in the brand color.
> /hyperframes Turn this into a ~60-second 1080x1920 vertical explainer: [paste your text]. One idea per scene, big typography, diagrams over stock footage, brand color #FF5533 on off-black. Male TTS voice, calm. Embedded captions, keywords highlighted in the brand color.
<DocsVideo
title="HyperFrames video: Example Explainer"
@@ -38,20 +38,18 @@ A faceless explainer. Your text becomes a narrated video — an article, notes,
topic, a brief. Every visual is invented per scene: typography, abstract
graphics, diagrams, data-viz.
The [`/faceless-explainer`](/prompting/overview) workflow does four things. It
picks a design system. It reshapes your text into a teaching story. It generates
its own TTS narration. Then it builds the video frame by frame.
Asked for an explainer, the agent settles a look, shapes your text into a
teaching story, generates TTS narration, and builds the video scene by scene.
**Faceless means there is nothing to capture.** No site, no footage, no asset
inventory. The visuals are designed downstream.
Pick a different workflow when you do have something to show:
Give it the source instead when you do have something to show:
- A product to sell → [`/product-launch-video`](/prompting/product-launch)
- A real site to show → [`/product-launch-video`](/prompting/product-launch)
with a tour brief
- A GitHub PR → [`/pr-to-video`](/prompting/code-and-prs)
- Unsure → start at `/hyperframes`
- A product to sell → its URL ([product launch](/prompting/product-launch))
- A real site to show → its URL with a tour brief
([product launch](/prompting/product-launch))
- A GitHub PR → the PR link ([code and PRs](/prompting/code-and-prs))
## The knobs that matter
@@ -60,17 +58,17 @@ technique.
| Knob | What to say | Why it matters |
| --- | --- | --- |
| **Verbatim vs summarized** | "use my wording verbatim" or "restructure it freely" | The workflow asks once. Verbatim keeps your voice but locks the word count. Summarized lets it cut and reorder for pace. |
| **Verbatim vs summarized** | "use my wording verbatim" or "restructure it freely" | Verbatim keeps your voice but locks the word count. Summarized lets it cut and reorder for pace. |
| **Duration** | "~60 seconds", never "60 seconds" | With a script, the narration sets the real length. A hard number forces the agent to trim or pad the words. |
| **Scene density** | "one idea per scene" | A faceless scene has one invented focal to animate. Two ideas leave nothing to build the motion around. It reads as a text dump. |
| **Angle** | "concept" / "how-to" / "listicle" / "story" | The angle decides the story shape. The workflow reshapes your text into it instead of reading your paragraphs in order. |
| **Angle** | "concept" / "how-to" / "listicle" / "story" | The angle decides the story shape. The agent reshapes your text into it instead of reading your paragraphs in order. |
| **Caption style** | "embedded captions, keywords highlighted in the accent color" | Captions are burned in. Naming the highlight color ties them to your palette instead of a default pill. |
| **Palette** | "brand color #FF5533 on off-black" | There is no site to borrow from, so the preset supplies a full palette. A named accent and ground personalize it. |
| **Voice** | "male TTS voice, calm" / "warm female voice" | Gender and tone are prompt words. The provider is a workflow decision. |
| **Palette** | "brand color #FF5533 on off-black" | There is no site to borrow from, so the agent picks a palette. A named accent and ground personalize it. |
| **Voice** | "male TTS voice, calm" / "warm female voice" | Gender and tone are prompt words. The provider is the agent's decision. |
<Tip>
Scene density is the single biggest quality lever here. "One idea per scene"
turns a dense paragraph into a paced sequence. The workflow reorders and
turns a dense paragraph into a paced sequence. The agent reorders and
compresses your text to hit it. That is what makes an explainer teach instead
of recite.
</Tip>
@@ -79,10 +77,10 @@ technique.
<AccordionGroup>
<Accordion title="30-second landscape topic explainer (16:9)">
> /faceless-explainer Make a ~30-second 1920x1080 explainer on how HTTPS keeps a request private, for a non-technical audience — the takeaway: your data is sealed before it leaves the browser. Concept angle: one idea per scene, big geometric type, a simple lock-and-key diagram as the centerpiece (swap the metaphor with the topic). Near-black ink on off-white with a deep-blue accent. Female TTS voice, warm and clear. Embedded captions, key terms highlighted in the accent color.
> /hyperframes Make a ~30-second 1920x1080 explainer on how HTTPS keeps a request private, for a non-technical audience — the takeaway: your data is sealed before it leaves the browser. Concept angle: one idea per scene, big geometric type, a simple lock-and-key diagram as the centerpiece (swap the metaphor with the topic). Near-black ink on off-white with a deep-blue accent. Female TTS voice, warm and clear. Embedded captions, key terms highlighted in the accent color.
Shorter runtime, landscape for YouTube or an embed. Fewer scenes means the
topic has to compress. Naming the takeaway tells the workflow what to keep.
topic has to compress. Naming the takeaway tells the agent what to keep.
<DocsVideo
title="HyperFrames video: Variant Explainer Landscape"
@@ -93,18 +91,18 @@ technique.
</Accordion>
<Accordion title="Listicle">
> /faceless-explainer Make a ~45-second 1080x1920 listicle: "5 habits of fast-shipping teams". Listicle angle — one habit per scene, each with a big number and a one-line label, escalating energy toward #1. Off-black with a lime accent. Male TTS voice, upbeat. Embedded captions, the habit label highlighted each scene.
> /hyperframes Make a ~45-second 1080x1920 listicle: "5 habits of fast-shipping teams". Listicle angle — one habit per scene, each with a big number and a one-line label, escalating energy toward #1. Off-black with a lime accent. Male TTS voice, upbeat. Embedded captions, the habit label highlighted each scene.
The listicle angle gives each item its own scene. Every scene reuses the same
number-and-label shape. The result reads as a countdown, not a wall of
points.
</Accordion>
<Accordion title="How-to with diagrams">
> /faceless-explainer Make a ~60-second 1920x1080 how-to on setting up a CI pipeline, for developers. How-to angle: one step per scene, each built around a simple node-and-arrow diagram that draws on as the narration explains it. Charcoal with a teal accent. Calm male TTS voice. Embedded captions, the step name highlighted.
> /hyperframes Make a ~60-second 1920x1080 how-to on setting up a CI pipeline, for developers. How-to angle: one step per scene, each built around a simple node-and-arrow diagram that draws on as the narration explains it. Charcoal with a teal accent. Calm male TTS voice. Embedded captions, the step name highlighted.
A how-to leans on diagrams as the load-bearing visual. Describe the diagram
*shape* per step — "node-and-arrow", or "a pipeline that fills left to
right". Let the workflow invent the specifics.
right". Let the agent invent the specifics.
</Accordion>
</AccordionGroup>
@@ -128,15 +126,15 @@ single focal. Overload the scene and there is no clear thing to animate.
faceless explainer invents its visuals.
- ❌ `pull screenshots from the site and explain the feature`
- ✅ that's a site or product video — use
[`/product-launch-video`](/prompting/product-launch)
- ✅ that's a site or product video — give it the URL
([product launch](/prompting/product-launch))
**Leaving the look unspecified when you care.** There is no brand to read, so
the preset picks the palette. If you have colors, name them.
the agent picks the palette. If you have colors, name them.
- ❌ `make it look on-brand`
- ✅ `brand color #FF5533 on off-black`
The workflow this level rides is documented at [Faceless explainer](/guides/faceless-explainer) — what it takes as input, what it asks you before it builds, and what it returns.
The matching guide is [Faceless explainer](/guides/faceless-explainer) — what to give the agent, what it confirms before building, and what it returns.
*Next: [Code changes and PRs](/prompting/code-and-prs) — point a workflow at a merged GitHub PR instead of a blank page.*
*Next: [Code changes and PRs](/prompting/code-and-prs) — point the agent at a merged GitHub PR instead of a blank page.*
+1 -1
View File
@@ -162,7 +162,7 @@ for you. Naming the layout you want still helps.
> Layer the headline *behind* the presenter so their silhouette occludes the text.
<Note>
Two mechanics the workflow skills handle automatically, from the [Remove
Two mechanics the skills handle automatically, from the [Remove
background guide](/guides/remove-background#compositing-patterns-and-pitfalls):
- A cutout that reveals into view goes inside a non-timed `<div>`, and the
+15 -23
View File
@@ -9,11 +9,11 @@ Five rides in, five different inputs — a site, plain text, a PR, footage, a tr
## Your first win
One prompt to [`/motion-graphics`](/prompting/overview), with the beats spelled out, is enough for a finished motion graphic — no technique required yet.
One `/hyperframes` prompt, with the beats spelled out, is enough for a finished motion graphic — no technique required yet.
The canonical shape: routed, spec'd, beat-timestamped, copy quoted, technique pinned, gaps closed.
> /motion-graphics Make an 8-second 1920x1080 video. Beat 1 (0-4s): dark macOS terminal types "npx skills add heygen-com/hyperframes" character by character, then hold on the blinking cursor. Beat 2 (4-5s): the terminal shatters into fragments. Beat 3 (5-8s): bold white kinetic text on black slams in word by word, snappy: "YOU JUST MADE THIS / WITH HYPERFRAMES." Adapt the `code-typing` and `vfx-shatter` registry blocks; hand-author the kinetic text. No narration, no image or media files.
> /hyperframes Make an 8-second 1920x1080 video. Beat 1 (0-4s): dark macOS terminal types "npx skills add heygen-com/hyperframes" character by character, then hold on the blinking cursor. Beat 2 (4-5s): the terminal shatters into fragments. Beat 3 (5-8s): bold white kinetic text on black slams in word by word, snappy: "YOU JUST MADE THIS / WITH HYPERFRAMES." Adapt the `code-typing` and `vfx-shatter` registry blocks; hand-author the kinetic text. No narration, no image or media files.
<DocsVideo
title="HyperFrames video: Validate Anatomy Terminal"
@@ -27,21 +27,13 @@ The canonical shape: routed, spec'd, beat-timestamped, copy quoted, technique pi
A motion graphic is a short, design-led piece where **motion is the message** — kinetic typography, a stat count-up, a chart hit, a logo sting, a lower-third or social overlay, an animated map, tweet, or headline. It's usually under 10 seconds (up to ~30s), has no narration and no live-action subject, and renders to an MP4 or a transparent overlay.
Route with `/motion-graphics`. The workflow is autonomous by design — at most one clarifying question, then straight through to render. Reach for a different workflow when the piece grows past what "motion is the message" covers:
| If the piece is… | Route instead |
| --- | --- |
| Longer, multi-scene, or narrated | `/general-video` |
| A narrated video of a website | `/product-launch-video` |
| A topic explainer with a voice-over | `/faceless-explainer` |
| A product promo / launch | `/product-launch-video` |
| Captions on existing footage | `/embedded-captions` |
When the piece grows past what "motion is the message" covers — longer, multi-scene, or narrated; a video of a website or product; a topic explainer with a voice-over; captions on existing footage — it's a different brief. Say so, and see [product launch](/prompting/product-launch), [explainers](/prompting/explainers), or [captions](/prompting/captions-and-talking-heads) for what to ask.
## The knobs that matter
What you can already steer from the prompt, before you've learned any technique.
**Duration.** Keep it short — motion graphics live under 10 seconds, up to ~30. A 2-second idea stretched to 8 feels slow no matter how it's animated; if the piece genuinely needs multiple scenes or a longer runtime, it's a `/general-video`, not a motion graphic.
**Duration.** Keep it short — motion graphics live under 10 seconds, up to ~30. A 2-second idea stretched to 8 feels slow no matter how it's animated; if the piece genuinely needs multiple scenes or a longer runtime, it's a multi-scene video, not a motion graphic.
**MP4 vs transparent overlay.** The default output is an MP4. Ask for a transparent overlay — a lower-third, a callout, a bug meant to composite over other footage — and the render targets `webm` or `mov` with alpha. Transparency only makes sense when part of the frame is *meant* to be empty. A full-frame design (its own background, edge-to-edge composition) has nothing to be transparent, so asking for a transparent WebM there produces either an opaque file or a broken-looking one. Say "transparent overlay, alpha channel" only for pieces designed to sit on top of something else.
@@ -55,16 +47,16 @@ Each reuses a registry block, so the agent composes rather than hand-building fr
<AccordionGroup>
<Accordion title="Stat count-up">
> /motion-graphics 6-second 1920x1080 video, dark navy background. Beat 1 (0-1s): label "ARR" fades up small, top-center. Beat 2 (1-4s): a giant number counts up to $4.2M with an odometer roll, easing out as it lands. Beat 3 (4-6s): "+312% YoY" stamps in below in green, then everything settles into a gentle ambient idle (subtle breathing scale, slow particle drift). Use the `apple-money-count` registry block as base. No narration.
> /hyperframes 6-second 1920x1080 video, dark navy background. Beat 1 (0-1s): label "ARR" fades up small, top-center. Beat 2 (1-4s): a giant number counts up to $4.2M with an odometer roll, easing out as it lands. Beat 3 (4-6s): "+312% YoY" stamps in below in green, then everything settles into a gentle ambient idle (subtle breathing scale, slow particle drift). Use the `apple-money-count` registry block as base. No narration.
</Accordion>
<Accordion title="Logo sting">
> /motion-graphics 5-second 1920x1080 logo sting. Beat 1 (0-2s): the word "ACME" assembles from scattered particles. Beat 2 (2-3s): full-frame `swirl-vortex` shader transition. Beat 3 (3-5s): logo lockup + tagline "Ship faster." settles on white, then holds with a subtle ambient idle (slow breathing scale). Use `code-particle-assemble` for the assembly.
> /hyperframes 5-second 1920x1080 logo sting. Beat 1 (0-2s): the word "ACME" assembles from scattered particles. Beat 2 (2-3s): full-frame `swirl-vortex` shader transition. Beat 3 (3-5s): logo lockup + tagline "Ship faster." settles on white, then holds with a subtle ambient idle (slow breathing scale). Use `code-particle-assemble` for the assembly.
</Accordion>
<Accordion title="Animated tweet">
> /motion-graphics 7-second 1080x1350 vertical video. A real tweet card (handle @hyperframes, text "we render video from HTML now. no timeline UI. just code.") slides up over a soft animated gradient, likes counter ticks 0→1.2K, then the card tilts in 3D and a highlight sweeps the second sentence. Settle into a gentle ambient idle on the card at the end. Use the `x-post` and `vfx-liquid-background` registry blocks. No narration, no image or media files.
> /hyperframes 7-second 1080x1350 vertical video. A real tweet card (handle @hyperframes, text "we render video from HTML now. no timeline UI. just code.") slides up over a soft animated gradient, likes counter ticks 0→1.2K, then the card tilts in 3D and a highlight sweeps the second sentence. Settle into a gentle ambient idle on the card at the end. Use the `x-post` and `vfx-liquid-background` registry blocks. No narration, no image or media files.
</Accordion>
<Accordion title="Map route">
> /motion-graphics 8-second 1920x1080 video. Dark world map, a glowing arc animates from San Francisco to Tokyo over 3s, destination pin drops with a pulse, then camera zooms into Tokyo and the label "LATENCY: 89ms" types on. Use the `nyc-paris-flight` registry block as the base pattern, restyle to teal on charcoal. No narration.
> /hyperframes 8-second 1920x1080 video. Dark world map, a glowing arc animates from San Francisco to Tokyo over 3s, destination pin drops with a pulse, then camera zooms into Tokyo and the label "LATENCY: 89ms" types on. Use the `nyc-paris-flight` registry block as the base pattern, restyle to teal on charcoal. No narration.
</Accordion>
</AccordionGroup>
@@ -74,18 +66,18 @@ Each reuses a registry block, so the agent composes rather than hand-building fr
- ❌ `an 8s full-screen stat count-up on dark navy — export as a transparent WebM`
- ✅ `an 8s stat count-up on dark navy, MP4` — or, for a bug to composite over footage: `just the count-up chip, no background, transparent overlay (webm)`
**Narration on a motion-is-the-message piece.** Motion graphics are unnarrated by definition — the visual carries it. A voice-over means a different workflow.
- ❌ `/motion-graphics a 10s logo sting with a voice-over reading the tagline`
- ✅ `/motion-graphics a 10s logo sting, no narration` — for a spoken track, use `/faceless-explainer` or `/general-video`.
**Narration on a motion-is-the-message piece.** Motion graphics are unnarrated by definition — the visual carries it. A voice-over makes it a narrated video.
- ❌ `/hyperframes a 10s logo sting with a voice-over reading the tagline`
- ✅ `/hyperframes a 10s logo sting, no narration` — for a spoken track, ask for a narrated explainer instead.
**Stretching a short idea long.** Runtime is a knob, and past ~30s a single motion beat runs out of things to do.
- ❌ `a 45-second kinetic-type piece of one headline`
- ✅ `an 8-second kinetic-type piece of one headline` — or promote it to a multi-scene `/general-video`.
- ✅ `an 8-second kinetic-type piece of one headline` — or promote it to a multi-scene video.
<Tip>
For the six-part skeleton and the per-beat content formula these prompts share, see [Prompt anatomy](/prompting/anatomy); for the full set of run-verified examples, [Verified examples](/prompting/examples). Unsure whether your ask is a motion graphic at all? Start at the router in `/hyperframes`.
For the six-part skeleton and the per-beat content formula these prompts share, see [Prompt anatomy](/prompting/anatomy); for the full set of run-verified examples, [Verified examples](/prompting/examples).
</Tip>
The workflow this level rides is documented at [Motion graphics](/guides/motion-graphics) — what it takes as input, what it asks you before it builds, and what it returns.
The matching guide is [Motion graphics](/guides/motion-graphics) — what to give the agent, what it confirms before building, and what it returns.
*Next: [Prompt anatomy](/prompting/anatomy) — you've been riding workflows through six one-prompt inputs; Level 2 opens the prompt itself.*
*Next: [Prompt anatomy](/prompting/anatomy) — you've ridden six one-prompt inputs; Level 2 opens the prompt itself.*
+17 -17
View File
@@ -1,6 +1,6 @@
---
title: Music videos and slideshows
description: "Two music- and slide-driven outputs that look alike in a brief but ship differently — a beat-synced MP4 versus a navigable deck — and how to route to the right one."
description: "Two music- and slide-driven outputs that look alike in a brief but ship differently — a beat-synced MP4 versus a navigable deck — and how to ask for the right one."
---
import { DocsVideo } from "/snippets/docs-video.jsx";
@@ -9,11 +9,11 @@ You've dressed footage and turned a PR into a story — now the driving input ch
## Your first win
One prompt to [`/music-to-video`](/prompting/overview), pointed at a track and some photos, is enough for a finished beat-synced video — no technique required yet.
One `/hyperframes` prompt, pointed at a track and some photos, is enough for a finished beat-synced video — no technique required yet.
The verified starting point: photos cut to a track, exported to a square MP4.
> /music-to-video 20-second 1080x1080 video from ./track.mp3 (pick the best 20 seconds of the track) and the 8 photos in ./shots/. Cut on the beat grid, one photo per bar, punch-in on downbeats, `whip-pan` transitions on phrase changes. End on the last photo with "SUMMER '26" in condensed caps. No TTS.
> /hyperframes 20-second 1080x1080 video from ./track.mp3 (pick the best 20 seconds of the track) and the 8 photos in ./shots/. Cut on the beat grid, one photo per bar, punch-in on downbeats, `whip-pan` transitions on phrase changes. End on the last photo with "SUMMER '26" in condensed caps. No TTS.
<DocsVideo
title="HyperFrames video: Example Music Slideshow"
@@ -27,14 +27,14 @@ Every timing decision here is delegated to the track's own analysis — you desc
## Two outputs that a brief blurs together
"Make a slideshow from these photos and this track" and "make a slideshow deck for my pitch" both say *slideshow*, but they produce different things and route to different workflows. Name the output you want up front.
"Make a slideshow from these photos and this track" and "make a slideshow deck for my pitch" both say *slideshow*, but they produce different things. Name the output you want up front.
| You want | Route | Output |
| You want | Skill | Output |
| --- | --- | --- |
| Photos / clips cut to a music track, exported as a video | `/music-to-video` | A beat-synced **MP4** with audio |
| Photos / clips cut to a music track, exported as a video | `/hyperframes` | A beat-synced **MP4** with audio |
| A presentation you click through — slides, reveals, speaker notes | `/slideshow` | A **navigable deck**, not an MP4 |
`/music-to-video` turns a **music track** — an audio file, a video to pull audio from, or a track generated from a mood brief — into a beat-synced video. The music drives all pacing; any photos or clips you supply are cut onto the same beat grid, and a complete video needs zero assets (typography carries it otherwise). There is no narration and no website capture.
A music video starts from a **music track** — an audio file, a video to pull audio from, or a track generated from a mood brief. The music drives all pacing; any photos or clips you supply are cut onto the same beat grid, and a complete video needs zero assets (typography carries it otherwise).
`/slideshow` authors a HyperFrames deck — discrete slides with fragment reveals, hotspot branching, and a built-in presenter mode with speaker notes. Its output is the **running deck**, served with `hyperframes present`. Do not point `render` at a deck: it resolves only the first scene and emits a silently truncated MP4. If the user didn't explicitly ask for a slideshow, the skill confirms the deck route before authoring — that's a routing decision, not a style preference. One authoring detail worth knowing: fragment reveal times are absolute positions on the deck's master timeline, not per-slide offsets.
@@ -42,11 +42,11 @@ Every timing decision here is delegated to the track's own analysis — you desc
What you can already steer from the prompt, before you've learned any technique.
**The beat grid.** `/music-to-video` analyzes the track once into energy phases, onsets, rolls, silences, hard stops, and phrases, then cuts at real musical changes. You steer *how* it cuts, not *when*: "one photo per bar" sets cut density, "punch-in on downbeats" adds the accent, "transitions on phrase changes" reserves the visible moves for structural boundaries. On genuinely rhythmic music the grid is trustworthy and cuts snap to the beat; on calm music the grid is a metronome the analyzer imposed, so the skill paces by phrase and energy instead of hard-cutting — say "let it flow, no hard cuts" if the track is ambient.
**The beat grid.** The agent analyzes the track (`hyperframes beats`, `hyperframes audio`) for beats, onsets, and sections, then cuts at real musical changes. You steer *how* it cuts, not *when*: "one photo per bar" sets cut density, "punch-in on downbeats" adds the accent, "transitions on phrase changes" reserves the visible moves for structural boundaries. On genuinely rhythmic music the grid is trustworthy and cuts snap to the beat; on calm music the grid is a metronome the analyzer imposed, so the agent paces by phrase and energy instead of hard-cutting — say "let it flow, no hard cuts" if the track is ambient.
**Track section — describe, don't timestamp.** Ask for "the best 20 seconds" or "the verse into the hook" and let the analyzer choose boundaries that land on musical anchors. Hard timestamps ("use 0:32–0:52") cut mid-phrase and fight the grid.
**Asset supply.** Zero assets is valid — typography and templates carry a complete video. Any photos or clips you hand it are woven in *on the same beat grid* (beat-cut or Ken Burns), so more assets means more to cut between, not a different pacing model. Point at a directory ("the 8 photos in ./shots/") and name the end card.
**Asset supply.** Zero assets is valid — typography carries a complete video. Any photos or clips you hand it are woven in *on the same beat grid* (beat-cut or Ken Burns), so more assets means more to cut between, not a different pacing model. Point at a directory ("the 8 photos in ./shots/") and name the end card.
**Deck structure (slideshow).** Fragments (reveal hold-points), hotspots + branch sequences (off-line detail slides), and presenter notes are the deck's structural knobs. Ask for them by name — "reveal the bullets as fragments", "branch to a detail slide from a hotspot", "add speaker notes" — and the island wiring follows.
@@ -54,14 +54,14 @@ What you can already steer from the prompt, before you've learned any technique.
<AccordionGroup>
<Accordion title="Lyric video">
> /music-to-video 30-second 1080x1920 lyric video from ./song.mp3 (pick the strongest 30-second section — a verse into the hook). Transcribe the vocals for word timing. Lines rise in one at a time on the beat, big condensed type on a dark grain background; the hook lands with each word punching in on its downbeat. Keyword in each line highlighted in acid green. No photos — typography only. No TTS.
> /hyperframes 30-second 1080x1920 lyric video from ./song.mp3 (pick the strongest 30-second section — a verse into the hook). Transcribe the vocals for word timing. Lines rise in one at a time on the beat, big condensed type on a dark grain background; the hook lands with each word punching in on its downbeat. Keyword in each line highlighted in acid green. No photos — typography only. No TTS.
Word-level timing comes from transcribing the track (or from lyrics you paste, placed on the beat grid). No supplied assets needed — type is the whole video.
</Accordion>
<Accordion title="Kinetic promo from a mood brief (no track)">
> /music-to-video 15-second 1080x1080 kinetic promo. No track supplied — generate one: driving synthwave, high energy. Cut hard on the beat: full-frame word cards ("FASTER", "SHARPER", "SHIP IT") slam in on downbeats, alternating black/white with inverted type, a glitch flash on each phrase change. End on the wordmark "NOVA" holding with a subtle ambient idle. No TTS.
> /hyperframes 15-second 1080x1080 kinetic promo. No track supplied — generate one: driving synthwave, high energy. Cut hard on the beat: full-frame word cards ("FASTER", "SHARPER", "SHIP IT") slam in on downbeats, alternating black/white with inverted type, a glitch flash on each phrase change. End on the wordmark "NOVA" holding with a subtle ambient idle. No TTS.
With no audio supplied, the track is generated from the mood you describe; the beat grid it produces still drives every cut. Fast, high-energy briefs suit this workflow best.
With no audio supplied, the track is generated from the mood you describe; the beat grid it produces still drives every cut.
</Accordion>
<Accordion title="Presentation deck (slideshow)">
> /slideshow Build a 5-slide pitch deck, 1920x1080. One idea per slide, each headline a complete-sentence claim (not a label), punchline first. Slide 2 reveals three pain points one at a time as fragments. Slide 3 shows bottom-up market math (accounts × ACV), not a bare "$40B TAM". Add presenter notes to every slide, and a hotspot on slide 3 that branches to a "sizing methodology" detail slide. I'll present it with `hyperframes present`.
@@ -72,18 +72,18 @@ What you can already steer from the prompt, before you've learned any technique.
## Failure modes
**Hard track timestamps.** The whole point of `/music-to-video` is that the track's structure sets the cuts. A literal time window ignores the analyzed beat grid and lands cuts mid-phrase.
**Hard track timestamps.** The whole point of a music video is that the track's structure sets the cuts. A literal time window ignores the analyzed beat grid and lands cuts mid-phrase.
- ❌ `use the section from 0:32 to 0:52`
- ✅ `pick the best 20 seconds of the track`
**Expecting an MP4 from `/slideshow`.** A deck is authored as several top-level scenes with no master-root composition, so `render` resolves only the first one and truncates. The supported outputs are the live `present` deck and per-slide snapshots.
- ❌ `/slideshow ... then render it to deck.mp4`
- ✅ `/slideshow ... I'll present it with hyperframes present` — or, if you actually need a rendered video, use `/music-to-video` (beat-synced) or `/general-video`.
- ✅ `/slideshow ... I'll present it with hyperframes present` — or, if you actually need a rendered video, ask `/hyperframes` for one.
**Wrong workflow for the output.** Photos set to music that you'll export and post is `/music-to-video`; a thing you click through live is `/slideshow`. Picking by the word "slideshow" alone builds the wrong deliverable.
**Wrong deliverable for the output.** Photos set to music that you'll export and post is a video; a thing you click through live is a `/slideshow` deck. Picking by the word "slideshow" alone builds the wrong deliverable.
<Tip>
Both prompts here are unnarrated. `/music-to-video` has no TTS by design; if you want a spoken voice-over instead of a music bed, that's a different workflow (see the router in `/hyperframes`). For the six-part skeleton these prompts share, see [Prompt anatomy](/prompting/anatomy); for adjectives that map to eases and transitions, [Vocabulary](/prompting/vocabulary).
The music prompts here are unnarrated; ask for a voice-over if you want one. For the six-part skeleton these prompts share, see [Prompt anatomy](/prompting/anatomy); for adjectives that map to eases and transitions, [Vocabulary](/prompting/vocabulary).
</Tip>
<Note>
@@ -101,6 +101,6 @@ This is the clause in the [full capstone prompt](/prompting/capstone#the-prompt-
/>
*That clause, rendered — the region cut from the finished film.*
The workflow this level rides is documented at [Music to video](/guides/music-to-video) — what it takes as input, what it asks you before it builds, and what it returns.
The matching guide is [Music to video](/guides/music-to-video) — what to give the agent, what it confirms before building, and what it returns.
*Next: [Motion graphics](/prompting/motion-graphics) — the shortest one yet, a single motion graphic where motion alone is the message.*
+2 -2
View File
@@ -5,7 +5,7 @@ description: "Prompt named lower-third and social-post overlay blocks with timin
import { DocsVideo } from "/snippets/docs-video.jsx";
Level 1's founder-clip.mp4 got named lower-thirds and card overlays through `/talking-head-recut`, which designs bespoke cards synced to that specific transcript. This chapter is the reverse case: ready-made lower-third and social-overlay blocks you drop into a composition you're building from scratch, no existing footage required — the same vocabulary, pointed at a scene instead of a shot.
Level 1's founder-clip.mp4 got named lower-thirds and card overlays designed as bespoke cards synced to that specific transcript. This chapter is the reverse case: ready-made lower-third and social-overlay blocks you drop into a composition you're building from scratch, no existing footage required — the same vocabulary, pointed at a scene instead of a shot.
## What overlays do and when they trigger
@@ -77,7 +77,7 @@ Quote the exact copy — unquoted names and titles get paraphrased (see [anatomy
*Rendered from the prompt above over stand-in scenes, unedited — the card's built-in like-tap ticks 12.4K → 12.5K.*
> /motion-graphics Transparent overlay only — a [`spotify-card`](/catalog/blocks/spotify-card) now-playing widget animating in, bottom-left. Export as transparent WebM so I can drop it over footage in my editor.
> /hyperframes Transparent overlay only — a [`spotify-card`](/catalog/blocks/spotify-card) now-playing widget animating in, bottom-left. Export as transparent WebM so I can drop it over footage in my editor.
<DocsVideo
title="HyperFrames video: Overlay Spotify Preview"
+15 -38
View File
@@ -24,7 +24,7 @@ The guide is one arc, novice to advanced. Each level is what you can do once you
| Level | What you can do after it |
| --- | --- |
| **1 — [Your first video](/prompting/product-launch)** | Get a finished video from one prompt — the workflow fills the gaps (palette, pacing, structure) for you. |
| **1 — [Your first video](/prompting/product-launch)** | Get a finished video from one prompt — the agent fills the gaps (palette, pacing, structure) for you. |
| **2 — [Control](/prompting/anatomy)** | Name the parts yourself: route, spec, beats, copy, technique, negatives — the skeleton that removes the decisions agents get wrong. |
| **3 — [Life](/prompting/motion)** | Motion and transitions that read premium instead of like a slideshow. |
| **4 — [Substance](/prompting/code-blocks)** | Add real capabilities: code animation, data-viz, overlays, captions, generated artwork, VFX, 3D. |
@@ -40,39 +40,27 @@ Install the skills in your project (or globally for your agent):
npx skills add heygen-com/hyperframes
```
The installer shows a picker. Select the **core skills** below — every project needs them. In Claude Code, restart the session after installing; the skills register as **slash commands**. Start at `/hyperframes`: it orients you to the whole surface and routes "make me a video" requests to the right workflow.
The installer shows a picker. Select the **core skills** below — every project needs them. In Claude Code, restart the session after installing; the skills register as **slash commands**. Make any video by asking for it; the `/hyperframes` skill carries the contract and the references (each kind of film, story, motion, look, sound, review, captions, overlays).
**Core skills — install all of these**
| Slash command | What it loads |
| ----------------------- | -------------------------------------------------------------------------- |
| `/hyperframes` | **Read first.** The entry skill — capability map + video router; sends "make me a video" intent to the right workflow |
| `/hyperframes-core` | Composition contract — HTML structure, `data-*` attributes, clips, tracks |
| `/hyperframes-animation`| All animation — motion rules, scene blueprints, transitions, and the runtime adapters (GSAP, Lottie, Three.js, Anime.js, CSS, WAAPI, TypeGPU) |
| `/hyperframes-creative` | Creative direction — design spec, palettes, typography, narration, beats |
| `/hyperframes-cli` | Dev-loop CLI — `init`, `lint`, `check`, `preview`, `render`, `doctor` |
| `/media-use` | Media OS — TTS voiceover (`tts`), `transcribe`, `remove-background`, plus BGM / SFX / image resolution (a host app's own music and sound tools come first) |
| `/hyperframes-registry` | Search the catalog before hand-building a look; install via `hyperframes add` |
| `/hyperframes-keyframes`| Seek-safe keyframe authoring across runtimes, plus `hyperframes keyframes` diagnostics |
| `/general-video` | The general authoring workflow — multi-scene pieces, reels, montages, remixes, and the home of **companion mode**; the fallback when no workflow below fits |
| `/hyperframes` | **Read first.** The render contract and work loop, plus references for format, motion, look, captions, whole films (website, topic, PR, music, short motion piece), talking-head overlays, and storyboards |
| `/hyperframes-cli` | The CLI by job — scaffold, check, look at frames, edit the timeline, preview, render, publish, project history |
| `/hyperframes-registry` | About 450 ready-made blocks and components — search before hand-building a look; install via `hyperframes add` |
| `/media-use` | Music, sound effects, images, voiceover, avatar and generated video, transcripts, cutouts, colour grades — HeyGen (the `heygen` CLI; OAuth sign-in carries a free allowance) or free local tools (Kokoro TTS, Parakeet/whisper, background removal, bundled SFX) — and mixing placed audio |
**Optional workflows — add the ones that match your inputs** (`/hyperframes` routes to whichever you've installed)
**Optional skills — add the ones that match your inputs**
| Slash command | Input → output |
| ------------------------ | --------------------------------------------------------------------------- |
| `/product-launch-video` | Any website URL / brief / script → launch or promo video, or a site tour / showcase |
| `/faceless-explainer` | Arbitrary text (no URL, no footage) → faceless explainer — every visual invented (typography, diagrams, data-viz) |
| `/pr-to-video` | A GitHub PR → code-change explainer |
| `/embedded-captions` | An existing talking-head video → the same footage with captions / subtitles |
| `/talking-head-recut` | An existing talking-head video → footage packaged with transcript-synced graphic overlays (kinetic titles, lower-thirds, PiP) |
| `/motion-graphics` | A logo / stat / tweet / brief → a short (~10s) unnarrated motion graphic (kinetic type, count-up, logo sting) — MP4 or transparent overlay |
| `/music-to-video` | A music track — a file, a video's audio, or one generated from a mood brief → beat-synced video (lyric / slideshow / kinetic promo); your images optional, cut on the beat |
| `/slideshow` | A deck outline or slides → navigable presentation with presenter mode (not a rendered MP4) |
| `/remotion-to-hyperframes` | Port an existing Remotion (React) composition to HyperFrames HTML |
| `/remotion-to-hyperframes` | Import Remotion or other video code and renders, verified frame by frame |
| `/figma` | A Figma file / frame / URL → imported assets, brand tokens, and reconstructed motion |
<Tip>
To skip the picker and install everything (core + every workflow) in one shot, run `npx hyperframes skills`. And start HyperFrames prompts with `/hyperframes` (or invoke the skill another way for non-Claude agents) — it loads the routing + composition context explicitly so the agent picks the right workflow and gets the rules right the first time.
To skip the picker and install everything in one shot, run `npx hyperframes skills`. And start HyperFrames prompts with `/hyperframes` (or invoke the skill another way for non-Claude agents) — it loads the composition rules explicitly so the agent gets them right the first time.
</Tip>
@@ -114,25 +102,14 @@ Warm-start prompts produce richer, more grounded videos because the agent is wri
The four prompts above illustrate shape, not results — every prompt in this guide that ships with an embedded render was run exactly as written, and the gallery of those lives in [Verified examples](/prompting/examples).
## The interview: what the agent asks first
## Before the build
Either shape starts a short interview before anything is built. This isn't the agent stalling — it's the intent layer turning "make me a video" into a confirmed brief, so the build never has to guess the things you'd have corrected afterward. The agent confirms the route, then asks that workflow's must-have questions (recommended answer first, so most are one-word confirmations). When the selected workflow supports both run shapes, it then closes with two questions that shape the run itself:
The agent builds what you asked for and fills the gaps itself. A question, a hold, or a loose idea gets an answer or a proposal first, not an edit. Say what must be exact (a claim, a name, a length) and leave the rest.
| Question | Your options |
| --- | --- |
| **Storyboard?** | `yes` — review the plan in chat, stills on a board (`boards/storyboard.html`), and the build pass by pass (recommended past a couple of scenes) · `no` — one finished video from the confirmed brief |
| **Automation or companion?** | `automation` — the matched workflow's pipeline executes the brief end to end · `companion` — build it together in `/general-video` with every HyperFrames capability on the table |
The two are independent — all four combinations are valid, and a companion run goes through the same review passes when you said yes to the storyboard. Four routes skip both questions because neither has anything to add: `/motion-graphics` (the piece is seconds long), `/slideshow` (the deliverable is a navigable deck, not a rendered video), and `/embedded-captions` and `/talking-head-recut` (the footage is untouched, so there's no storyboard to review).
In a hurry, skip the whole conversation by saying so:
Want to review the plan before anything is built? Ask for a storyboard: the agent writes `STORYBOARD.md` in the project root, one section per frame, and builds each frame against it once you approve. In a hurry, skip any questions by saying so:
> Just build it — don't ask me anything.
That locks `automation` with no storyboard wherever those apply, and every question the agent would have asked becomes a stated default in its heads-up instead.
Everything the interview settles is written to **`BRIEF.md`** in the project root — the brief is the artifact, not the chat. A later session (or a different agent) resumes from that file and asks nothing again; to change an answer, edit `BRIEF.md` rather than re-explaining yourself in the prompt.
## Recommended workflow
1. `npx hyperframes init my-video` — scaffold a project (skills install automatically)
@@ -149,9 +126,9 @@ Everything the interview settles is written to **`BRIEF.md`** in the project roo
## What a prompt buys you
Three prompts from this guide and their unedited renders — one workflow warm start, one registry-block piece, one dense freeform spec:
Three prompts from this guide and their unedited renders — one warm start from a URL, one registry-block piece, one dense freeform spec:
> /product-launch-video Make a 45-second 1920x1080 launch video for https://linear.app. Energetic but minimal, use the site's own palette and screenshots. Structure: hook stating the problem, 3 feature beats with UI captures and one-line captions, end card with logo + "Try it free". Female TTS voice, confident tone, subtle electronic BGM under -18dB.
> /hyperframes Make a 45-second 1920x1080 launch video for https://linear.app. Energetic but minimal, use the site's own palette and screenshots. Structure: hook stating the problem, 3 feature beats with UI captures and one-line captions, end card with logo + "Try it free". Female TTS voice, confident tone, subtle electronic BGM under -18dB.
<DocsVideo
title="HyperFrames video: Example Product Launch"
@@ -160,7 +137,7 @@ Three prompts from this guide and their unedited renders — one workflow warm s
/>
*Rendered from the prompt above, unedited.*
> /motion-graphics 6-second 1920x1080 video, dark navy background. Beat 1 (0-1s): label "ARR" fades up small, top-center. Beat 2 (1-4s): a giant number counts up to $4.2M with an odometer roll, easing out as it lands. Beat 3 (4-6s): "+312% YoY" stamps in below in green, then everything settles into a gentle ambient idle. Use the `apple-money-count` registry block as base. No narration.
> /hyperframes 6-second 1920x1080 video, dark navy background. Beat 1 (0-1s): label "ARR" fades up small, top-center. Beat 2 (1-4s): a giant number counts up to $4.2M with an odometer roll, easing out as it lands. Beat 3 (4-6s): "+312% YoY" stamps in below in green, then everything settles into a gentle ambient idle. Use the `apple-money-count` registry block as base. No narration.
<DocsVideo
title="HyperFrames video: Example Stat Countup"
+20 -36
View File
@@ -5,15 +5,15 @@ description: "What to say to turn a product URL, a script, or a brief into a lau
import { DocsVideo } from "/snippets/docs-video.jsx";
This is Level 1 of the Prompt Guide: a run of one-prompt rides, each handing a workflow a single ask and getting a finished video back. You don't need any technique yet — one sentence describing your product, aimed at the right workflow, is already enough for a first draft.
This is Level 1 of the Prompt Guide: a run of one-prompt rides, each handing the agent a single ask and getting a finished video back. You don't need any technique yet — one sentence describing your product is already enough for a first draft.
## Your first win
One prompt to [`/product-launch-video`](/prompting/overview), aimed at a live URL, is enough for a finished launch video — no technique required yet.
One `/hyperframes` prompt, aimed at a live URL, is enough for a finished launch video — no technique required yet.
Verified, from the [examples](/prompting/examples) page — a 45-second launch from a live URL:
> /product-launch-video Make a 45-second 1920x1080 launch video for https://linear.app. Energetic but minimal, use the site's own palette and screenshots. Structure: hook stating the problem, 3 feature beats with UI captures and one-line captions, end card with logo + "Try it free". Female TTS voice, confident tone, subtle electronic BGM under -18dB.
> /hyperframes Make a 45-second 1920x1080 launch video for https://linear.app. Energetic but minimal, use the site's own palette and screenshots. Structure: hook stating the problem, 3 feature beats with UI captures and one-line captions, end card with logo + "Try it free". Female TTS voice, confident tone, subtle electronic BGM under -18dB.
<DocsVideo
title="HyperFrames video: Example Product Launch"
@@ -27,46 +27,39 @@ Read the [anatomy](/prompting/anatomy) of that skeleton — route, spec, structu
## What this makes
A launch or promo that *sells*: SaaS promos, feature reveals, product demos, app and company launches. The [`/product-launch-video`](/prompting/overview) workflow captures the product's site (or takes a pasted script), reads its brand, writes a story, and builds it frame by frame.
A launch or promo that *sells*: SaaS promos, feature reveals, product demos, app and company launches. Given a URL, the agent captures the product's site (or takes a pasted script), reads its brand, writes a story, and builds it scene by scene.
**Route it right — the distinction is intent, not input:**
| You want… | Route |
| --- | --- |
| To market, launch, promote, or reveal a product (the default for any commercial URL) | `/product-launch-video` |
| A video *of* a general site — a portfolio / blog / docs / landing-page tour or showcase, not a sales pitch | `/product-launch-video` too — say *tour* or *showcase* in the brief (see the [website-to-video guide](/guides/product-launch-video)) |
Both route to the same workflow now; what changes is the brief. "Promo for our site" is a launch — sell it. A neutral walkthrough of a docs site is a tour — say so, and the workflow shows the site's own captured screens instead of pitching. Unsure → start at `/hyperframes` and let it route.
**Say what the video is for — the distinction is intent, not input.** "Promo for our site" is a launch — sell it. A video *of* a general site — a portfolio, blog, docs, or landing-page tour or showcase — is a tour: say *tour* or *showcase* in the brief, and the agent shows the site's own captured screens instead of pitching (see the [website video guide](/guides/product-launch-video)).
## The knobs that matter
What you can already steer from the prompt, before you've learned any technique. Set the ones you care about; leave the rest to the workflow's taste.
What you can already steer from the prompt, before you've learned any technique. Set the ones you care about; leave the rest to the agent's taste.
| Knob | What to say | Why it matters |
| --- | --- | --- |
| **Capture vs no-capture** | Give a URL to capture the real site; give a script or brief for the no-capture path; give just a brand name to have the agent find the URL | Capture borrows the real palette and screenshots; no-capture invents visuals, so it needs your brand colors named |
| **Palette source** | "use the site's own palette" | The workflow remixes the captured brand tokens onto its frame preset — you get the product's real colors, not a generic theme |
| **Structure** | "hook stating the problem, N feature beats with captions, end card with CTA" | The workflow leads value-before-evidence; naming the beats keeps the hook and CTA from getting dropped |
| **Voice & tone** | "female TTS voice, confident" / "calm male voice" | Voice gender and tone are prompt words; the provider itself is a workflow decision — see the [skill](/prompting/overview) for provider mechanics |
| **Capture vs no capture** | Give a URL to capture the real site; give a script or brief when there is no site to capture | Capture borrows the real palette and screenshots; without it the visuals are invented, so name your brand colors |
| **Palette source** | "use the site's own palette" | The agent builds from the captured brand tokens — you get the product's real colors, not a generic theme |
| **Structure** | "hook stating the problem, N feature beats with captions, end card with CTA" | Naming the beats keeps the hook and CTA from getting dropped |
| **Voice & tone** | "female TTS voice, confident" / "calm male voice" | Voice gender and tone are prompt words; the provider itself is the agent's decision — see [media and audio](/prompting/media-and-audio) for provider mechanics |
| **BGM level** | "subtle electronic BGM under -18dB" (or "no BGM") | A stated ceiling keeps music under the voice; leave it off entirely for a teaser |
| **Length & destination** | "~45 seconds", "9:16 for TikTok" | Sweet spot is 30-90s; destination sets the aspect (16:9 embed · 1:1 feed · 9:16 Shorts) |
<Tip>
Duration and destination are the two cheapest, highest-leverage things to state. Everything else the workflow will choose well if you stay quiet — see [the specification dial](/prompting/specification-dial) for how much to delegate.
Duration and destination are the two cheapest, highest-leverage things to state. Everything else the agent will choose well if you stay quiet — see [the specification dial](/prompting/specification-dial) for how much to delegate.
</Tip>
## Variants
<AccordionGroup>
<Accordion title="20-second vertical teaser (9:16)">
> /product-launch-video Make a ~20-second 1080x1920 teaser for https://linear.app. Super minimal, just the hook. Beat 1 (0-4s): the problem in one line, big type. Beat 2 (4-16s): two feature beats, one UI capture each with a three-word caption. Beat 3 (16-20s): logo + "Try it free" end card, then settle into a gentle idle. Use the site's own palette. Female TTS voice, confident; no BGM.
> /hyperframes Make a ~20-second 1080x1920 teaser for https://linear.app. Super minimal, just the hook. Beat 1 (0-4s): the problem in one line, big type. Beat 2 (4-16s): two feature beats, one UI capture each with a three-word caption. Beat 3 (16-20s): logo + "Try it free" end card, then settle into a gentle idle. Use the site's own palette. Female TTS voice, confident; no BGM.
A teaser trades feature coverage for pace — fewer beats, one idea each. Keep the destination (Shorts / TikTok → 9:16) and let the workflow scale the story to the shorter runtime.
A teaser trades feature coverage for pace — fewer beats, one idea each. Keep the destination (Shorts / TikTok → 9:16) and let the agent scale the story to the shorter runtime.
</Accordion>
<Accordion title="Pasted script, no capture">
> /product-launch-video Make a ~20-second 1920x1080 launch video from this script — use it verbatim: "Your CRM is three hours of busywork a day. AutoCRM logs every call, email, and meeting for you. 200 teams already switched. Try it free at autocrmhq.com." No site to capture — invent clean product-y visuals from the script. Male TTS voice, calm and confident; subtle BGM under -18dB.
> /hyperframes Make a ~20-second 1920x1080 launch video from this script — use it verbatim: "Your CRM is three hours of busywork a day. AutoCRM logs every call, email, and meeting for you. 200 teams already switched. Try it free at autocrmhq.com." No site to capture — invent clean product-y visuals from the script. Male TTS voice, calm and confident; subtle BGM under -18dB.
With no URL the workflow takes the no-capture path: no screenshots, no site palette to borrow, so name your brand colors and fonts if you have them (or the agent invents a palette). Verbatim scripts set the duration — figure roughly 130 spoken words per minute, so a 30-second video wants a 65–70 word script. Saying "use it verbatim" pre-answers the workflow's keep-or-restructure question.
With no URL there is nothing to capture: no screenshots, no site palette to borrow, so name your brand colors and fonts if you have them (or the agent invents a palette). Verbatim scripts set the duration — figure roughly 130 spoken words per minute, so a 30-second video wants a 65–70 word script. Saying "use it verbatim" settles whether the agent may restructure your words.
<DocsVideo
title="HyperFrames video: Variant Launch Script"
@@ -76,15 +69,10 @@ What you can already steer from the prompt, before you've learned any technique.
*Rendered from this prompt shape with a different, longer script (78 words, fictional dev-tool "Relay"), unedited — it ran 35.5s because the words set the length.*
</Accordion>
<Accordion title="Brand name only (agent finds the URL)">
> /product-launch-video Make a ~45-second 1920x1080 launch video for Linear. Find the official site, capture it, and use its own palette and screenshots. Angle: speed as the whole pitch. End card with logo + "Try it free". Confident female TTS voice, subtle electronic BGM under -18dB.
<Accordion title="Site tour instead (same URL, tour brief)">
> /hyperframes Make a 30-second 1920x1080 tour of https://example.com built from its own screenshots. Calm, editorial pace — show the homepage, two inner pages, and the footer. Full narration, warm male voice. This is a showcase, not a sales pitch.
Given a name instead of a link, the workflow searches for the official URL, confirms it in one line, then captures — you get the site-grounded result without pasting the link yourself.
</Accordion>
<Accordion title="Site tour instead (same workflow, tour brief)">
> /product-launch-video Make a 30-second 1920x1080 tour of https://example.com built from its own screenshots. Calm, editorial pace — show the homepage, two inner pages, and the footer. Full narration, warm male voice. This is a showcase, not a sales pitch.
Reach for this when the goal is to *show the site*, not sell a product. It builds from captured screenshots and the site's brand assets. A launch or promo — even from the same URL — belongs to `/product-launch-video`.
Reach for this when the goal is to *show the site*, not sell a product. It builds from captured screenshots and the site's brand assets. A launch or promo from the same URL is a different brief — ask to sell it.
</Accordion>
</AccordionGroup>
@@ -94,15 +82,11 @@ What you can already steer from the prompt, before you've learned any technique.
- ❌ `a 45-second launch video from this exact script: ...`
- ✅ `a ~45-second launch video from this script: ...`
**Overriding the designed structure.** The workflow builds hook → value → evidence → CTA for a reason; drop the hook and the promo never answers "why should I care?"
**Dropping the structure.** Hook → value → evidence → CTA is there for a reason; drop the hook and the promo never answers "why should I care?"
- ❌ `skip the intro, just list all six features back to back`
- ✅ `hook stating the problem, then 3 feature beats, then the CTA end card`
**Fighting the art-directed preset.** Each workflow adopts a frame preset and injects transitions; forcing a foreign theme yields a compromise, not your look (see [rules and anti-patterns](/prompting/rules-and-anti-patterns)).
- ❌ `/product-launch-video ... plain white, no transitions between scenes`
- ✅ pick the angle and tone, and let the preset carry the visual system
**Assuming the agent knows your assets.** On the no-capture path there's no site to read; an unnamed logo or color is invented.
**Assuming the agent knows your assets.** Without a URL to capture there's no site to read; an unnamed logo or color is invented.
- ❌ `use our brand colors`
- ✅ `brand colors #5E6AD2 on off-black; logo at assets/logo.svg`
+6 -6
View File
@@ -7,11 +7,11 @@ This is the last chapter of Level 6, and the last before the capstone level —
## What this makes
Not a new video — a **translation**. The [`/remotion-to-hyperframes`](/prompting/overview) workflow reads an existing Remotion (React) composition's source and rewrites it as HyperFrames HTML: the same frames, the same timing, the same visual output, running on GSAP instead of React's frame-callback model.
Not a new video — a **translation**. The [`/remotion-to-hyperframes`](/prompting/overview) skill reads an existing Remotion (React) composition's source and rewrites it as HyperFrames HTML: the same frames, the same timing, the same visual output, running on GSAP instead of React's frame-callback model.
This is a migration, not a creation workflow. It only fires on an explicit ask to port, convert, migrate, or translate a **Remotion** source. Two things that look similar but aren't this:
- "Make something like my Remotion video" with no ask to migrate the actual source → a fresh build, [`/general-video`](/prompting/overview#one-time-setup).
- "Make something like my Remotion video" with no ask to migrate the actual source → a fresh build with [`/hyperframes`](/prompting/overview#one-time-setup).
- A non-Remotion source (After Effects, Framer Motion, plain React/CSS) → there's no Remotion source to translate; re-create it natively instead.
The direction is one-way. There's no export back out to Remotion or any other framework.
@@ -30,7 +30,7 @@ The skill lints the source before translating anything, and **refuses** rather t
- **Third-party React UI kits** — MUI, Chakra, Mantine, antd, shadcn, Radix, NextUI. There's no HTML/CSS/GSAP equivalent to translate them into.
- **`@remotion/lambda` deploy config** — not a blocker. It's dropped (deployment config, not animation) and the rest of the composition still translates.
If your source hits a blocker, expect the agent to stop and explain the interop recommendation rather than push through — that's the correct behavior, not a failure. Don't ask it to "just approximate" past a blocker; the whole point of this workflow is a validated, measured translation, not a best-effort guess.
If your source hits a blocker, expect the agent to stop and explain the interop recommendation rather than push through — that's the correct behavior, not a failure. Don't ask it to "just approximate" past a blocker; the whole point of this skill is a validated, measured translation, not a best-effort guess.
## Validation is not optional
@@ -38,7 +38,7 @@ Every translation is graded against the Remotion original by SSIM (structural si
> After translating, render both the Remotion original and the HyperFrames version and report the SSIM diff.
A translation that "looks right" in preview can still measure meaningfully below the validated baseline — that's the difference this workflow is built to catch.
A translation that "looks right" in preview can still measure meaningfully below the validated baseline — that's the difference this skill is built to catch.
## The knobs that matter
@@ -51,9 +51,9 @@ A translation that "looks right" in preview can still measure meaningfully below
## Common failure modes
**Asking for a fresh build with Remotion as inspiration.** If you don't have an actual Remotion source to translate, this isn't the workflow — it's a normal build.
**Asking for a fresh build with Remotion as inspiration.** If you don't have an actual Remotion source to translate, this isn't the skill — it's a normal build.
- ❌ `/remotion-to-hyperframes make something like my Remotion intro`
- ✅ `/general-video make an intro like this: [description]` — no Remotion source, no migration
- ✅ `/hyperframes make an intro like this: [description]` — no Remotion source, no migration
**Asking it to push past a lint blocker.** A `useState`-driven composition isn't a "translate it anyway, best effort" situation — it's a different problem (runtime interop), and forcing a translation produces something that looks plausible but isn't frame-accurate.
- ❌ `just convert it, don't worry about the state stuff`
+2 -2
View File
@@ -53,7 +53,7 @@ Transparency also only *means something* on a design that has empty space to see
<Note>
A transparent render also depends on the composition leaving `html` / `body` backgrounds unset — the transparency comes through only where nothing is painted. The workflow skills handle this; see the [Rendering guide](/guides/rendering#transparent-video) if you're hand-authoring an overlay.
A transparent render also depends on the composition leaving `html` / `body` backgrounds unset — the transparency comes through only where nothing is painted. The skills handle this; see the [Rendering guide](/guides/rendering#transparent-video) if you're hand-authoring an overlay.
</Note>
## Resolution and framerate
@@ -101,7 +101,7 @@ Lambda is not the only remote target. **HeyGen-hosted cloud rendering** ([guide]
## Preview before you commit the slow render
The cheapest way to avoid a wasted `high`/4K/HDR render is to judge the frame first. The habit the workflow skills follow:
The cheapest way to avoid a wasted `high`/4K/HDR render is to judge the frame first. The habit the skills follow:
1. Keep `preview` running and scrub the timeline — same runtime as the render, so what you see is what you get.
2. Iterate with `draft` renders when you need a real file to check.
@@ -113,10 +113,6 @@ Each one causes friction or wrong output for a specific engine reason — with t
- ❌ `use my logo`
- ✅ `use assets/logo.svg`
**Don't override a workflow's designed style.** Each workflow skill carries an art-directed preset; fighting it produces a compromise, not your theme.
- ❌ `/pr-to-video ... dark theme`
- ✅ let the preset carry the look, or use a freeform build when you need full style control
**Don't hard-time a verbatim script.** With supplied narration text, duration follows the spoken words.
- ❌ `a 60-second explainer from this script: ...`
- ✅ `a ~60-second explainer from this script: ...`
+1 -1
View File
@@ -14,7 +14,7 @@ Prompt the **plan** once instead — the throughline, the job each frame does, t
This narrative vocabulary is a writing discipline, not extra `STORYBOARD.md` schema. The workflow translates your plan into the smaller machine-readable shape the build consumes.
<Note>
"Storyboard" is also a question the agent asks in the [opening interview](/prompting/overview#the-interview-what-the-agent-asks-first). Say yes there and the plan (in chat), the stills (a board at `boards/storyboard.html`), and the build all get reviewed with you pass by pass. That answer changes the review process, not the route. Either way, the plan this page teaches is what the build works from.
Asking for a storyboard [before the build](/prompting/overview#before-the-build) means you review the plan before anything is built. Either way, the plan this page teaches is what the build works from.
</Note>
## Prompt the plan, not the scenes
+1 -1
View File
@@ -54,7 +54,7 @@ Special-purpose seams: [`flash-through-white`](/catalog/blocks/flash-through-whi
Name the block and the seam — transitions are the one place where per-seam control usually beats letting the agent decide.
> /general-video Six-scene SaaS explainer. Use [`whip-pan`](/catalog/blocks/whip-pan) as the primary transition between related points, and one [`cinematic-zoom`](/catalog/blocks/cinematic-zoom) into the final pricing reveal. Medium energy, ~0.4s each.
> /hyperframes Six-scene SaaS explainer. Use [`whip-pan`](/catalog/blocks/whip-pan) as the primary transition between related points, and one [`cinematic-zoom`](/catalog/blocks/cinematic-zoom) into the final pricing reveal. Medium energy, ~0.4s each.
<DocsVideo
title="HyperFrames video: Validate Transitions"
+4 -4
View File
@@ -11,7 +11,7 @@ Your motion-graphics terminal piece from Level 1 already named one of these —
To put your product UI inside a real phone or laptop, name [`vfx-iphone-device`](/catalog/blocks/vfx-iphone-device) — real GLTF iPhone 15 Pro Max and MacBook Pro models with live HTML-in-Canvas screen content, a product-review camera choreography, and a 360° turntable. For a styled iOS/macOS *environment* (home screen, desktop, dock) rather than a bare device, reach for the liquid-glass system blocks below.
> /product-launch-video 15-second 1920x1080 video. Our dashboard UI lives on the screen of a real iPhone 15 Pro Max that turntables slowly under product-review lighting, then a MacBook Pro slides in beside it showing the same UI wider. Use the `vfx-iphone-device` registry block. No narration.
> /hyperframes 15-second 1920x1080 video. Our dashboard UI lives on the screen of a real iPhone 15 Pro Max that turntables slowly under product-review lighting, then a MacBook Pro slides in beside it showing the same UI wider. Use the `vfx-iphone-device` registry block. No narration.
<DocsVideo
title="HyperFrames video: Validate Device Mockup"
@@ -38,7 +38,7 @@ The liquid-glass blocks are frosted-glass Apple-style UI floating over an aurora
The four `liquid-glass-*` panel blocks share the aurora-shader stage, so they compose cleanly into one scene; `ios26-liquid-glass` and `macos-tahoe-liquid-glass` are complete device environments and generally stand alone.
> /motion-graphics 8-second 1920x1080 video. Frosted glass notification cards drift in and stack over an aurora shader background, each reading a fake alert ("Build passed", "Deploy live", "0 incidents"). Real translucency — the aurora must be visible through each card. Smoke the glass enough to keep white text above 3:1 against the brightest part of the aurora. No audio.
> /hyperframes 8-second 1920x1080 video. Frosted glass notification cards drift in and stack over an aurora shader background, each reading a fake alert ("Build passed", "Deploy live", "0 incidents"). Real translucency — the aurora must be visible through each card. Smoke the glass enough to keep white text above 3:1 against the brightest part of the aurora. No audio.
<DocsVideo
title="HyperFrames video: Glass Notify"
@@ -69,7 +69,7 @@ The `vfx-*` blocks are single cinematic beats — spend them on a transition or
| HTML floating over an organic liquid surface | [`vfx-liquid-background`](/catalog/blocks/vfx-liquid-background) | 12s |
| A dramatic text reveal with chromatic shadow rays | [`vfx-text-cursor`](/catalog/blocks/vfx-text-cursor) | 8s |
> /motion-graphics 8-second 1920x1080 video. Beat 1 (0-4s): a landing-page hero holds under directional light. Beat 2 (4-6s): the whole page shatters into glass fragments that scatter. Beat 3 (6-8s): bold white text slams in on black. Use the `vfx-shatter` registry block; the final beat reads "HTML IS VIDEO". No narration, no image or media files.
> /hyperframes 8-second 1920x1080 video. Beat 1 (0-4s): a landing-page hero holds under directional light. Beat 2 (4-6s): the whole page shatters into glass fragments that scatter. Beat 3 (6-8s): bold white text slams in on black. Use the `vfx-shatter` registry block; the final beat reads "HTML IS VIDEO". No narration, no image or media files.
<DocsVideo
title="HyperFrames video: Vfx Shatter"
@@ -98,7 +98,7 @@ The [Effects](/catalog/components/vignette) components are lightweight, pure-CSS
These are the ambient layer of the [motion grammar](/prompting/motion): grain and a slow `parallax-zoom` keep a "held" beat alive instead of freezing. Never write "holds motionless" — a still final second is the biggest cheap-motion tell; let a grain overlay and a 2% push carry the hold.
> /motion-graphics 6-second 1920x1080 video. A wordmark logo — "HYPERFRAMES" in platinum on near-black — settles center-frame, then holds — but keep it alive with a film grain overlay and a slow 3% push-in, plus one shimmer sweep across the wordmark at 4s. Use the `grain-overlay`, `parallax-zoom`, and `shimmer-sweep` registry components. No audio.
> /hyperframes 6-second 1920x1080 video. A wordmark logo — "HYPERFRAMES" in platinum on near-black — settles center-frame, then holds — but keep it alive with a film grain overlay and a slow 3% push-in, plus one shimmer sweep across the wordmark at 4s. Use the `grain-overlay`, `parallax-zoom`, and `shimmer-sweep` registry components. No audio.
<DocsVideo
title="HyperFrames video: Logo Polish"
+2 -2
View File
@@ -41,7 +41,7 @@ below is the same thing done by hand, if you would rather see each step.
For Claude Code, Copilot, VS Code, Cursor, or Gemini CLI, start with the
[plugin installation guide](/guides/plugins). It includes update settings for
each agent. Claude's plugin router is `/hyperframes:hyperframes`.
each agent. Claude's plugin entry skill is `/hyperframes:hyperframes`.
For standalone skills, run this in your project folder:
@@ -74,7 +74,7 @@ the same project.
## Related topics
<CardGroup cols={2}>
<Card title="Choose a specific workflow" icon="route" href="/workflows">
<Card title="Choose what to make" icon="route" href="/workflows">
Product launch, explainer, captions, recut, pull request, music, motion graphic, or
presentation.
</Card>
+4 -2
View File
@@ -30,7 +30,7 @@ can also sit on an `<hf-audio-group>`, where they apply to the group's whole bus
**Write the JSON attributes double-quoted, escaping the JSON's own quotes as
`&quot;` and `&` as `&amp;`** — as every example on this page does. The browser
reads them through `getAttribute` and does not mind either way, but
`scripts/carve.mjs` finds them with a `name="..."` regex, so a single-quoted
`carve.mjs` finds them with a `name="..."` regex, so a single-quoted
attribute is invisible to it: a carve reports no existing chain and overwrites
work it could not see.
@@ -445,8 +445,10 @@ effect and lane you built by hand exactly where it was.
### From the command line
The script ships with the `media-use` skill; `<SKILL_DIR>` is that skill's folder.
```bash
node <SKILL_DIR>/scripts/carve.mjs --comp index.html
node <SKILL_DIR>/audio/scripts/carve.mjs --comp index.html
```
That is the whole command — it finds the voice and the bed itself and prints what
+1 -1
View File
@@ -55,7 +55,7 @@
"authoringSkill": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{0,63}$",
"description": "Owning authoring-workflow skill slug (e.g. product-launch-video). Set by `hyperframes init --skill` or seeded from the first `hyperframes render --skill`; every render of this project is then attributed to it on anonymous telemetry, without re-passing the flag."
"description": "Owning authoring skill slug (e.g. hyperframes). Set by `hyperframes init --skill` or seeded from the first `hyperframes render --skill`; every render of this project is then attributed to it on anonymous telemetry, without re-passing the flag."
},
"registryItems": {
"type": "array",
+4 -4
View File
@@ -1,6 +1,6 @@
---
title: "Choose a workflow"
sidebarTitle: "Choose a workflow"
title: "Choose what to make"
sidebarTitle: "Choose what to make"
description: "Start from the material you already have."
---
@@ -9,8 +9,8 @@ import { WorkflowChooser } from "/snippets/workflow-chooser.jsx";
<WorkflowChooser />
When several source types matter equally, use **Direct a custom video**.
You can also make a plain `/hyperframes` request; the agent selects the matching
workflow after it understands what you want.
You can also make a plain `/hyperframes` request; the agent works out the kind
of video from what you ask for.
## Related topics
+3 -3
View File
@@ -26,9 +26,9 @@
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
"sync:package-subpaths": "node scripts/package-subpaths.mjs --write",
"check:package-subpaths": "node scripts/package-subpaths.mjs",
"lint": "bun run check:docs-snippet-motion && bun run check:studio-copy-dashes && bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:gcp-cloud-run-dockerfile && bun run check:package-cycles && bun run check:package-subpaths && bun run check:cli-process-ownership && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-skill-mirror.mjs",
"lint": "bun run check:docs-snippet-motion && bun run check:studio-copy-dashes && bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:gcp-cloud-run-dockerfile && bun run check:package-cycles && bun run check:package-subpaths && bun run check:cli-process-ownership && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-core-skills.mjs && node scripts/check-skill-mirror.mjs",
"check:gcp-cloud-run-dockerfile": "bun run --cwd packages/gcp-cloud-run test:dockerfile-workspaces",
"lint:skills": "tsx scripts/lint-skills.ts",
"lint:skills": "tsx scripts/lint-skills.ts && node scripts/check-core-skills.mjs",
"check:skill-mirror": "node scripts/check-skill-mirror.mjs",
"lint:fix": "oxlint --fix .",
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
@@ -54,7 +54,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/check-core-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
+1 -1
View File
@@ -1169,7 +1169,7 @@ describe("findBrowser — cache resolution", () => {
// Sibling env-var alias for the CLI resolver. The engine layer already
// honors `PRODUCER_HEADLESS_SHELL_PATH` (see
// `packages/engine/src/services/browserManager.ts`), and docs
// (skills/hyperframes-animation/adapters/typegpu.md,
// (skills/hyperframes-registry/references/discovery.md,
// packages/gcp-cloud-run/Dockerfile, examples/k8s-jobs/Dockerfile.example)
// all instruct users to set that name. Before this alias, `hyperframes
// check`/`snapshot`/`compare` — which all route through `openSettledCompositionPage`
@@ -0,0 +1,29 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { generateAgentPrompt } from "./agentPromptGenerator.js";
import type { DesignTokens } from "./types.js";
const tokens = {
title: "Example",
description: "",
cssVariables: {},
fonts: [],
colors: ["#111111"],
} as unknown as DesignTokens;
describe("generateAgentPrompt", () => {
let dir = "";
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it("points agents at the hyperframes skill's film reference", () => {
dir = mkdtempSync(join(tmpdir(), "hf-agent-prompt-"));
generateAgentPrompt(dir, "https://example.com", tokens, undefined, false);
const prompt = readFileSync(join(dir, "AGENTS.md"), "utf8");
expect(prompt).toContain("use the `hyperframes` skill and read its `references/film.md`");
expect(prompt).toContain("`references/types/launch.md`");
expect(prompt).not.toContain("product-launch-video");
expect(readFileSync(join(dir, "CLAUDE.md"), "utf8")).toBe(prompt);
});
});
@@ -6,8 +6,8 @@
* - CLAUDE.md — Claude Code convention
*
* This file generates a DATA INVENTORY that tells the AI agent what files
* exist and what they contain. The actual workflow lives in the
* product-launch-video skill — this file points agents there.
* exist and what they contain. How to make a film from it lives in the
* hyperframes skill's references/film.md and references/types/launch.md — this file points agents there.
*/
import { readdirSync, existsSync } from "node:fs";
@@ -199,7 +199,7 @@ function buildPrompt(
Source: ${url}
To create a video from this capture, use the \`product-launch-video\` skill.
To create a video from this capture, use the \`hyperframes\` skill and read its \`references/film.md\` (From a website) and \`references/types/launch.md\`.
## What's in This Capture
+113 -3
View File
@@ -54,7 +54,7 @@ import type {
LayoutOverflow,
LayoutRect,
} from "../utils/layoutAudit.js";
import type { SeekClock } from "../utils/checkTypes.js";
import type { CraftFrame, SeekClock } from "../utils/checkTypes.js";
import type { ProjectDir } from "../utils/project.js";
const PROJECT: ProjectDir = {
@@ -1094,6 +1094,7 @@ function reportWithFindings(overrides: Partial<CheckReport> = {}): CheckReport {
},
motion: { ...emptySection(), enabled: false, samples: 0 },
contrast: { ...emptySection(), enabled: true, samples: [], checked: 0, passed: 0 },
craft: { ...emptySection(), enabled: false, step: 0, samples: 0 },
hdr: { autoPromotion: null, inspection: "available" },
snapshots: { enabled: false, files: [], times: [], findingFiles: [] },
...overrides,
@@ -1996,6 +1997,112 @@ describe("check pipeline", () => {
});
});
describe("craft warnings", () => {
// A 20 s film: frame 0 empty, a 7-word line on screen 6.2–6.9 s, nothing moving 7.5–12.5 s, no sound.
function craftFrame(time: number): CraftFrame {
const line = time >= 6.2 && time < 6.9;
return {
time,
parts: line
? [
{
key: 1,
block: 1,
selector: "#title",
blockSelector: "#title",
sourceFile: "compositions/stat.html",
text: "Every team gets one shared calendar now",
opacity: 1,
fontPx: 96,
rect: { left: 160, top: 380, width: 1600, height: 220 },
captions: false,
},
]
: [],
held: false,
painted: time > 0,
signature: time >= 7.5 && time <= 12.5 ? "still" : `moving-${time}`,
};
}
function craftDriver(overrides: Partial<CheckAuditDriver> = {}): CheckAuditDriver {
return fakeDriver({
getDuration: vi.fn(async () => 20),
collectCraftFrame: vi.fn(async (time: number) => craftFrame(time)),
collectCraftFilm: vi.fn(async () => ({ soundSources: 0 })),
...overrides,
});
}
it("reports them as warnings that keep exit 0, even under --strict", async () => {
const { report } = await runScenario(craftDriver());
expect(report.craft.enabled).toBe(true);
expect(report.craft.findings.map((finding) => finding.code).sort()).toEqual([
"dead_stretch",
"film_silent",
"first_frame_empty",
"line_unread",
]);
expect(report.craft.findings.every((finding) => finding.severity === "warning")).toBe(true);
expect(report.craft.warningCount).toBe(4);
expect(report.ok).toBe(true);
expect(checkExitCode(report)).toBe(0);
const strict = await runScenario(craftDriver(), { strict: true });
expect(checkExitCode(strict.report)).toBe(0);
});
it("re-measures a short line between dense samples before judging it", async () => {
const driver = craftDriver();
const { report } = await runScenario(driver);
const line = report.craft.findings.find((finding) => finding.code === "line_unread");
expect(line?.seconds).toBeCloseTo(0.7, 1);
const sampled = vi.mocked(driver.collectCraftFrame!).mock.calls.map(([time]) => time);
expect(sampled.some((time) => Math.round(time * 1000) % 125 !== 0)).toBe(true);
});
it("samples a long film at 8 fps, finer than the overlap pass's capped grid", async () => {
// A 60 s film whose seven-word line shows 10.1-10.3 s, between two of the overlap grid's samples.
const driver = craftDriver({
getDuration: vi.fn(async () => 60),
collectCraftFrame: vi.fn(async (time: number) => {
const frame = craftFrame(time);
const line = time >= 10.1 && time < 10.3;
const part = craftFrame(6.5).parts[0];
return { ...frame, parts: line && part ? [part] : [], signature: `moving-${time}` };
}),
});
const { report } = await runScenario(driver);
const line = report.craft.findings.find((finding) => finding.code === "line_unread");
expect(line?.time).toBeCloseTo(10.1, 1);
});
it("skips the section for --range checks and for drivers without a craft sampler", async () => {
const ranged = await runScenario(craftDriver(), { range: [2, 8] });
expect(ranged.report.craft).toMatchObject({ enabled: false, findings: [] });
const { report } = await runScenario(fakeDriver({ getDuration: vi.fn(async () => 20) }));
expect(report.craft).toMatchObject({ enabled: false, findings: [] });
});
it("leaves stillness to sweep_static when the whole sweep is frozen", async () => {
const driver = craftDriver({
collectLayoutGeometry: vi.fn(async () => "frozen"),
collectCraftFrame: vi.fn(async (time: number) => ({
...craftFrame(time),
signature: "still",
})),
});
const { report } = await runScenario(driver);
expect(report.layout.findings.some((finding) => finding.code === "sweep_static")).toBe(true);
expect(report.craft.findings.some((finding) => finding.code === "dead_stretch")).toBe(false);
});
});
describe("keepsMoving frame check", () => {
function frozenLiveness(withinSelector?: string) {
const motion: MotionSpecResolution = {
@@ -2049,6 +2156,8 @@ describe("check pipeline", () => {
let seeked = 0;
const driver = fakeDriver({
...overrides,
// 9 s, so the last END_CARD_HOLD_SEC (6-9 s) a film may hold still stays clear of both spans.
getDuration: vi.fn(async () => 9),
seek: vi.fn(async (time: number) => {
seeked = time;
}),
@@ -2067,10 +2176,11 @@ describe("check pipeline", () => {
path: "/project/index.motion.json",
spec: { assertions: [{ kind: "keepsMoving" }] },
};
// A hold, a hard cut at 3 s into a second hold, then motion from 5.05 s.
// A hold, a hard cut at 3 s into a second hold, then motion from 5.05 s to the end at 9 s,
// clear of the last END_CARD_HOLD_SEC a film may hold still.
const shot = (index: number) => (index < 60 ? "a" : index < 101 ? "b" : String(index));
const driver = fakeDriver({
getDuration: vi.fn(async () => 6),
getDuration: vi.fn(async () => 9),
collectMotionFrame: vi.fn(async (time: number) => ({
time,
data: {},
+12
View File
@@ -557,6 +557,7 @@ function printHumanReport(report: CheckReport): void {
printLayoutSection("Layout", report.layout);
printSection("Motion", report.motion);
printContrastSection(report);
printCraftSection(report);
printSnapshotSection(report);
console.log();
const label = report.ok ? c.success("Check passed") : c.error("Check failed");
@@ -623,6 +624,17 @@ function printContrastSection(report: CheckReport): void {
printCounts(section);
}
function printCraftSection(report: CheckReport): void {
const section = report.craft;
if (!section.enabled) {
console.log();
console.log(c.bold("Craft"));
console.log(` ${c.dim("◇")} skipped`);
return;
}
printSection("Craft", section);
}
function printSnapshotSection(report: CheckReport): void {
console.log();
console.log(c.bold("Snapshots"));
@@ -7,8 +7,7 @@
// neighbouring panels, rounded pills, blurred glass and partial decorations.
// __contrastAuditRestoreIfPending() restores hidden paint if the screenshot or finish() throws.
// Duplicated in skills/hyperframes-creative/scripts/contrast-report.mjs (keep in sync); the
// "which rect to sample" decision is mirrored and unit-tested in contrast-sample.ts.
// The "which rect to sample" decision is mirrored and unit-tested in contrast-sample.ts.
/* eslint-disable */
window.__contrastAuditPrepare = function () {
+1 -4
View File
@@ -12,10 +12,7 @@
//
// This module hosts the pure decision so it can be unit-tested without a
// browser. The same logic is inlined into contrast-audit.browser.js (which is
// injected as a raw string and cannot import) and into
// skills/hyperframes-creative/scripts/contrast-report.mjs — keep all three in
// sync, mirroring the existing "WCAG math is duplicated" note at the top of
// contrast-audit.browser.js.
// injected as a raw string and cannot import) — keep both in sync.
import { parseColorRGBA, type Rgba } from "./contrast-bg.js";
+11 -30
View File
@@ -1,35 +1,16 @@
// Pure background-sampling-region logic for the WCAG contrast audit.
//
// The audit used to estimate an element's background by sampling a 4px ring
// just OUTSIDE its bounding box (with an own-opaque-background pre-check —
// see the historical note in contrast-bg.ts). That proximity-based estimate
// breaks down whenever what's immediately outside the box differs from
// what's actually behind the text inside it:
//
// - cross-component bleed: text sits near the edge of its own panel/layer,
// and a differently-colored sibling panel/layer starts just outside the
// text's bbox — the ring samples the neighbor, not the true background.
// - solid-fill pill/button with rounded corners achieved via a sibling
// shape (not a CSS background-color on an ancestor of the text) — same
// failure as above; the ownBg ancestor-walk never sees it.
// - translucent "glass" text over a backdrop-filter blur panel sized only
// a couple pixels larger than the text — the ring exits the panel and
// samples the raw, unblurred, untinted pixels behind it.
// - a translucent/decorative shape that only partially overlaps the ring,
// or sits entirely INSIDE the text's own bbox (never touching the ring
// at all) — the ring is structurally blind to it.
//
// The fix: hide the text's own paint (color/fill → transparent), take ONE
// screenshot with the glyphs invisible, then sample the REAL composited
// pixels directly INSIDE the element's own bbox — no proximity heuristic
// needed, because we're reading the exact pixels that were behind the
// glyphs. This module hosts the pure "which rect do we sample" decision
// (inset to dodge anti-aliased edge pixels, clamp to canvas bounds, and
// reject a rect that's too small to sample) so it's unit-testable without a
// browser. The same logic is inlined into contrast-audit.browser.js (which
// is injected as a raw string and cannot import) and into
// skills/hyperframes-creative/scripts/contrast-report.mjs — keep all three
// in sync.
// A ring sampled just OUTSIDE a text box reads whatever is next to the text,
// not what is behind it: a neighbouring panel, a rounded pill drawn by a
// sibling shape, a glass panel only a few pixels larger than the text, or a
// decoration that sits inside the box. So the audit hides the text's own
// paint (color/fill → transparent), takes ONE screenshot with the glyphs
// invisible, and samples the composited pixels INSIDE the element's own bbox.
// This module hosts the pure "which rect do we sample" decision (inset to
// dodge anti-aliased edge pixels, clamp to canvas bounds, and reject a rect
// that's too small to sample) so it's unit-testable without a browser. The
// same logic is inlined into contrast-audit.browser.js (which is injected as
// a raw string and cannot import) — keep both in sync.
export interface Rect {
x: number;
@@ -1,19 +1,14 @@
// @vitest-environment node
import { readdirSync, readFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const REPO_ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..", "..");
const read = (...parts: string[]): string => readFileSync(join(REPO_ROOT, ...parts), "utf8");
const skillTextFiles = (dir: string): string[] =>
readdirSync(dir, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && /\.(md|mjs|cjs|js|ts|json|html)$/.test(entry.name))
.map((entry) => join(entry.parentPath, entry.name));
describe("hyperframes-core contract docs", () => {
it("keeps a runnable root in the minimal composition skeleton", () => {
const minimal = read("skills", "hyperframes-core", "references", "minimal-composition.md");
describe("hyperframes contract docs", () => {
it("keeps a runnable root in the format reference's skeleton", () => {
const minimal = read("skills", "hyperframes", "references", "format.md");
// Structural pin: the skeleton must still declare a root the runtime can find
// and size. The prose around it is deliberately not pinned: asserting exact
@@ -26,18 +21,17 @@ describe("hyperframes-core contract docs", () => {
});
it("teaches check as the canonical quality gate", () => {
const skill = read("skills", "hyperframes-core", "SKILL.md");
const brief = read("skills", "hyperframes", "references", "brief-contract.md");
const skill = read("skills", "hyperframes", "SKILL.md");
expect(skill).toContain("`npx hyperframes check`");
expect(brief).toContain("`hyperframes check`");
expect(brief).not.toContain("`lint` / `validate` / `inspect`");
expect(skill).toContain("`npx hyperframes check --json`");
expect(skill).not.toContain("`lint` / `validate` / `inspect`");
});
it("requires actionable reproduction packets in CLI defect feedback", () => {
const skill = read("skills", "hyperframes-cli", "SKILL.md");
const skill = read("skills", "hyperframes-cli", "references", "feedback.md");
const renderReference = read("skills", "hyperframes-cli", "references", "preview-render.md");
expect(read("skills", "hyperframes-cli", "SKILL.md")).toContain("`references/feedback.md`");
expect(skill).toContain("reproduction packet");
expect(renderReference).toContain("REPRO COMMAND:");
expect(renderReference).toContain("EXPECTED / ACTUAL:");
@@ -47,7 +41,7 @@ describe("hyperframes-core contract docs", () => {
});
it("sends missing features and host-app friction as ratingless comments", () => {
const skill = read("skills", "hyperframes-cli", "SKILL.md");
const skill = read("skills", "hyperframes-cli", "references", "feedback.md");
expect(skill).toContain(
'npx hyperframes feedback --comment "MISSING FEATURE: <what the person asked for, in their words, with names, clients, figures and paths left out> | WORKAROUND:',
@@ -56,7 +50,7 @@ describe("hyperframes-core contract docs", () => {
});
it("mandates a composition-structure block for visual-defect feedback", () => {
const skill = read("skills", "hyperframes-cli", "SKILL.md");
const skill = read("skills", "hyperframes-cli", "references", "feedback.md");
const renderReference = read("skills", "hyperframes-cli", "references", "preview-render.md");
// Skill teaches the mandate at a high level.
@@ -70,7 +64,7 @@ describe("hyperframes-core contract docs", () => {
});
it("teaches safe cloud archive size remediation", () => {
const skill = read("skills", "hyperframes-cli", "SKILL.md");
const skill = read("skills", "hyperframes-cli", "references", "conventions.md");
const cloudReference = read("skills", "hyperframes-cli", "references", "cloud.md");
expect(skill).toContain("cloud render --dry-run --json");
@@ -99,10 +93,10 @@ describe("media treatment routing documentation", () => {
const mediaUse = read("skills", "media-use", "SKILL.md");
const treatments = read("skills", "media-use", "references", "media-treatments.md");
expect(router).toContain("dark/flat/boring footage");
expect(router).toContain("`/media-use`");
expect(router).toContain("`npx hyperframes media-treatment` (`/media-use`), never CSS filters");
expect(mediaUse).toContain("footage is dark, flat or dull");
expect(mediaUse).toContain("references/media-treatments.md");
expect(mediaUse).toContain("`hyperframes media-treatment`");
expect(mediaUse).toContain("`npx hyperframes media-treatment`");
expect(treatments).toContain("Persist pixel settings with `hyperframes media-treatment`");
expect(treatments).toContain("apply to the entire selected real `<img>` or");
expect(treatments).toContain("external segmentation/tracking tool");
@@ -145,41 +139,11 @@ describe("media treatment routing documentation", () => {
}
});
it("makes the motion-graphics build path run a live catalog search, not read a snapshot", () => {
// The failure this pins: a user inside /motion-graphics asked for "CRT scanlines
// and glitch effects" and the agent hand-authored both, while `caption-glitch-rgb`
// ("RGB chromatic aberration with CRT scanline overlay") ranks first for that exact
// query on either tier. Every reuse instruction in the workflow pointed at
// catalog-map.md, a hand-maintained snapshot, and none named the search. The search
// needs nothing installed, so "I forgot to install the components" was never the cause.
it("routes every skill that builds visuals through the live catalog search", () => {
// The search instruction once lived only in hyperframes-cli and
// hyperframes-registry, both loaded on demand, so an agent that never thought
// to search could not reach the doc telling it to search.
for (const file of [
["skills", "motion-graphics", "catalog-map.md"],
["skills", "motion-graphics", "agents", "director.md"],
["skills", "motion-graphics", "agents", "builder.md"],
]) {
expect(read(...file)).toContain("npx hyperframes catalog --query");
}
// And it must say the search stands alone, or the next reader re-derives the
// creator's wrong diagnosis: that a catalog you have not installed cannot be searched.
expect(read("skills", "motion-graphics", "catalog-map.md")).toContain(
"needs nothing installed",
);
});
it("routes every authoring workflow through the live catalog search, or documents why not", () => {
// The same failure one layer up. The search instruction lived only in
// hyperframes-cli and hyperframes-registry, both loaded on demand, and the
// registry skill's own trigger named the command ("use when running
// hyperframes catalog") — circular, because the agent that never thought to
// search could not reach the doc telling it to search. All ten workflow
// skills carried zero mentions of the command.
for (const file of [
["skills", "motion-graphics", "agents", "director.md"],
["skills", "product-launch-video", "SKILL.md"],
["skills", "faceless-explainer", "SKILL.md"],
["skills", "pr-to-video", "SKILL.md"],
["skills", "music-to-video", "SKILL.md"],
["skills", "general-video", "SKILL.md"],
["skills", "slideshow", "SKILL.md"],
["skills", "remotion-to-hyperframes", "SKILL.md"],
]) {
@@ -189,33 +153,14 @@ describe("media treatment routing documentation", () => {
// hid this bug. Every copy of the instruction has to kill it on the spot.
expect(doc, file.join("/")).toContain("nothing installed");
}
// The two workflows that deliberately do NOT search. Both compile their
// output through a closed authoring vocabulary (embedded-captions' locked
// caption engines, talking-head-recut's `data-anim` card kinds), and a
// registry item is a standalone composition with no place to mount. The
// exemption is written into each skill so the next reader does not close
// the gap with an instruction that would be false there.
for (const skill of ["embedded-captions", "talking-head-recut"]) {
expect(read("skills", skill, "SKILL.md"), skill).toContain(
"does not search the HyperFrames component registry",
);
// The exemption is a capability claim, so pin the capability and not only
// the sentence: the day either skill gains a way to install a registry item,
// this fails and the exemption has to be reconsidered. `data-composition-src`
// is deliberately not the signal; talking-head-recut mounts its own chapters
// with it, which is not a registry item.
for (const file of skillTextFiles(join(REPO_ROOT, "skills", skill))) {
expect(readFileSync(file, "utf8"), file).not.toMatch(
/hyperframes add\b|registry\/(blocks|components)\//,
);
}
}
expect(read("skills", "hyperframes", "SKILL.md")).toContain(
"search the registry (`/hyperframes-registry`)",
);
// The symptom-triggered description is the other half of the fix: the skill
// has to be reachable from the user naming an effect, not from the command.
const registrySkill = read("skills", "hyperframes-registry", "SKILL.md");
expect(registrySkill).toContain("Use BEFORE hand-building any named visual");
expect(registrySkill).toContain("Use it before hand-building any named visual");
expect(registrySkill).toContain("CRT scanlines");
});
+3 -3
View File
@@ -6,8 +6,8 @@ import { SKILL_SLUG } from "../telemetry/skill.js";
// ideally from its own bundled script, so it fires deterministically rather
// than relying on the agent to remember:
//
// npx hyperframes events --skill=product-launch-video
// npx hyperframes events --skill=product-launch-video --event=skill_completed --outcome=success
// npx hyperframes events --skill=slideshow
// npx hyperframes events --skill=slideshow --event=skill_completed --outcome=success
//
// Rides the SAME anonymous PostHog pipeline + consent gates as every other CLI
// event (DO_NOT_TRACK / telemetry opt-out, anonymous install UUID, IP stripped).
@@ -28,7 +28,7 @@ export default defineCommand({
args: {
skill: {
type: "string",
description: "Authoring skill slug, e.g. product-launch-video",
description: "Authoring skill slug, e.g. slideshow",
},
event: {
type: "string",
@@ -39,7 +39,6 @@ describe("figma storyboard doctrine pins", () => {
["CLAUDE.md", ["CLAUDE.md"]],
["README.md", ["README.md"]],
["docs/guides/skills.mdx", ["docs", "guides", "skills.mdx"]],
["skills/hyperframes/SKILL.md", ["skills", "hyperframes", "SKILL.md"]],
];
it("every catalog surface says reconstructed motion, never animatics", () => {
@@ -48,6 +47,8 @@ describe("figma storyboard doctrine pins", () => {
expect(content, label).toContain("reconstructed motion");
expect(content, label).not.toContain("animatics");
}
// The entry names /figma without a blurb, so it only has to keep the old word out.
expect(read("skills", "hyperframes", "SKILL.md")).not.toContain("animatics");
});
it("the source-of-truth description carries the frames-as-states framing", () => {
+2 -2
View File
@@ -77,7 +77,7 @@ describe("hyperframes init flag rename", () => {
expect(html).toContain('data-composition-id="main"');
expect(html).toContain("display: flex");
expect(html).toContain("align-items: center");
expect(html).toContain("font-family: Inter");
expect(html).toContain("font-family: var(--font-display)");
expect(html).toContain("tl.seek(0)");
expect(html).not.toMatch(/transform:\s*translate\(-50%/);
expect(html).toMatch(/#root\s*\{[^}]*width:\s*100%/);
@@ -108,7 +108,7 @@ describe("hyperframes init flag rename", () => {
const res = runInit([target, "--agent"]);
expect(res.status).toBe(0);
const html = readFileSync(join(target, "index.html"), "utf-8");
expect(html).toContain("font-family: Inter");
expect(html).toContain("font-family: var(--font-display)");
expect(html).toContain("tl.seek(0)");
expectScaffoldedScripts(target);
} finally {
+1 -1
View File
@@ -758,7 +758,7 @@ export default defineCommand({
skill: {
type: "string",
description:
"Owning authoring workflow slug (e.g. product-launch-video). Stamped into " +
"Owning authoring skill slug (e.g. hyperframes). Stamped into " +
"hyperframes.json so every render of this project is attributed to it on " +
"anonymous telemetry, without re-passing --skill on each render. Ignored unless it is a slug.",
},
@@ -2406,6 +2406,202 @@
return contentOverlapIssues(root, time);
};
// Craft sampling for check's craft section (utils/craftAudit.ts judges it in Node). One call per seek:
// every painted text element with its reading block, rendered font size and opacity; whether a data-hold
// element is on screen; whether anything besides the background paints; and the keepsMoving liveness
// signature from motion-signature.browser.js, so a still stretch here is the stillness motion_frozen sees.
const craftKeys = new WeakMap();
const craftAnchors = new WeakMap();
let craftNextKey = 0;
const INLINE_LEVEL_DISPLAY = /^(inline|inline-block|inline-flex|inline-grid|contents|ruby)$/;
const CRAFT_PAINTED_OPACITY = 0.2;
const PAINT_OPTIONS = { opacityProperty: true, visibilityProperty: true };
function craftKey(element) {
if (!craftKeys.has(element)) craftKeys.set(element, ++craftNextKey);
return craftKeys.get(element);
}
// Sampled at every seek of a dense grid, so the selector walk runs once per element.
function craftAnchor(element) {
if (!craftAnchors.has(element)) {
const source = element
.closest("[data-composition-file]")
?.getAttribute("data-composition-file");
craftAnchors.set(element, {
selector: uniqueSelectorFor(element),
sourceFile: source || "index.html",
});
}
return craftAnchors.get(element);
}
// Mirrors trackKindOf in @hyperframes/parsers (data-track-kind, legacy caption hosts) plus the caption
// stages __hyperframesGeometryCandidates already skips.
function isCaptionsText(element) {
for (let current = element; current; current = current.parentElement) {
if ((current.getAttribute("data-track-kind") || "").trim().toLowerCase() === "captions") {
return true;
}
const host =
current.hasAttribute("data-composition-id") || current.hasAttribute("data-composition-src");
if (!host) continue;
if (current.getAttribute("data-composition-id") === "captions") return true;
if (/(?:^|\s)caption[-_]/.test(current.getAttribute("class") || "")) return true;
}
return !!element.closest(".caption-layer, #caption-stage");
}
// The line a reader takes in at once: the nearest block-level ancestor-or-self, so a headline split into
// inline word or character spans is one line, while flex items and positioned spans stay their own.
function readingBlock(element, root) {
let current = element;
while (
current !== root &&
current.parentElement &&
!current.hasAttribute("data-composition-id") &&
INLINE_LEVEL_DISPLAY.test(getComputedStyle(current).display)
) {
current = current.parentElement;
}
return current;
}
function scaleProperty(style) {
const parts = (style.scale || "none")
.split(/\s+/)
.map((part) => Number.parseFloat(part))
.filter(Number.isFinite);
if (parts.length === 0) return 1;
return Math.sqrt(Math.abs(parts[0] * (parts.length > 1 ? parts[1] : parts[0])));
}
// A transform scales glyphs without changing the computed font-size: multiply in every ancestor's area
// scale (rotation-invariant), or the SVG screen matrix for SVG text.
function renderedFontPx(element, style) {
const font = parsePx(style.fontSize);
if (typeof element.getScreenCTM === "function" && element.ownerSVGElement) {
const ctm = element.getScreenCTM();
return ctm ? font * Math.sqrt(Math.abs(ctm.a * ctm.d - ctm.b * ctm.c)) : font;
}
let scale = 1;
for (let current = element; current; current = current.parentElement) {
const currentStyle = current === element ? style : getComputedStyle(current);
if (currentStyle.transform && currentStyle.transform !== "none") {
try {
const matrix = new DOMMatrixReadOnly(currentStyle.transform);
scale *= Math.sqrt(Math.abs(matrix.a * matrix.d - matrix.b * matrix.c));
} catch {
// An unparsable transform leaves the size as computed.
}
}
scale *= scaleProperty(currentStyle);
}
return font * scale;
}
function intersectsRect(rect, frame) {
return (
rect.right > frame.left &&
rect.left < frame.right &&
rect.bottom > frame.top &&
rect.top < frame.bottom
);
}
function craftTextParts(root, rootRect) {
const candidates = [];
for (const element of Array.from(root.querySelectorAll("*"))) {
if (!hasOwnTextCandidate(element, true) || !isVisibleElement(element, 0.05, false)) continue;
const rect = unionRects(visibleTextClientRects(element, true));
if (!rect || !intersectsRect(rect, rootRect)) continue;
candidates.push({ element, rect });
}
const clipped = candidates.filter((candidate) => hasClipPath(candidate.element));
const clippedAway = new Set();
if (clipped.length > 0) {
const restoreHits = restoreHitTesting(root);
try {
for (const candidate of clipped) {
if (isClippedAway(candidate.element)) clippedAway.add(candidate.element);
}
} finally {
restoreHits();
}
}
return candidates
.filter((candidate) => !clippedAway.has(candidate.element))
.map(({ element, rect }) => {
const block = readingBlock(element, root);
return {
key: craftKey(element),
block: craftKey(block),
...craftAnchor(element),
blockSelector: craftAnchor(block).selector,
text: textContentFor(element, true),
opacity: round(opacityChain(element)),
fontPx: round(renderedFontPx(element, getComputedStyle(element))),
rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
captions: isCaptionsText(element),
};
});
}
function isPaintedBox(element, rootRect) {
if (typeof element.checkVisibility === "function" && !element.checkVisibility(PAINT_OPTIONS)) {
return false;
}
const style = getComputedStyle(element);
if (style.display === "none" || style.visibility === "hidden") return false;
if (opacityChain(element) < CRAFT_PAINTED_OPACITY) return false;
const rect = element.getBoundingClientRect();
return rect.width >= 4 && rect.height >= 4 && intersectsRect(rect, rootRect);
}
function paintsContent(root, rootRect, parts) {
if (parts.some((part) => part.opacity >= CRAFT_PAINTED_OPACITY)) return true;
for (const element of Array.from(root.querySelectorAll("img, video, canvas, svg"))) {
if (element.parentElement?.closest("svg")) continue;
if (isPaintedBox(element, rootRect)) return true;
}
return false;
}
window.__hyperframesCraftSample = function collectCraftSample() {
const root =
document.querySelector("[data-composition-id][data-width][data-height]") ||
document.querySelector("[data-composition-id]") ||
document.body;
const rootRect = rootRectFor(root);
const parts = craftTextParts(root, rootRect);
const holds = [root, ...Array.from(root.querySelectorAll("[data-hold]"))].filter((element) =>
element.hasAttribute("data-hold"),
);
const shared = window.__hyperframesMotionSignature;
return {
parts,
held: holds.some((element) => isPaintedBox(element, rootRect)),
painted: paintsContent(root, rootRect, parts),
signature: shared
? shared.compositionSignature(shared.compositionRoot(), { quantize: true })
: "",
};
};
// Sound the render mixes: any unmuted <audio>, and a <video> the timing compiler marks audible
// (data-has-audio="true", or no data-has-audio on an unmuted video).
window.__hyperframesCraftFilm = function collectCraftFilm() {
let soundSources = 0;
for (const media of Array.from(document.querySelectorAll("audio, video"))) {
if (media.hasAttribute("muted")) continue;
const declared = media.getAttribute("data-has-audio");
const audible =
media.tagName.toUpperCase() === "AUDIO" || declared === null || declared === "true";
if (audible) soundSources += 1;
}
return { soundSources };
};
// The frozen-sweep fingerprint (window.__hyperframesLayoutGeometry, #U10 in
// checkPipeline.ts) lives in motion-signature.browser.js, the single owner
// of "what counts as motion" shared with motion-sample.browser.js's
@@ -1,15 +1,25 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import puppeteer, { type Browser } from "puppeteer-core";
import type { CraftFilm, CraftFrame } from "../utils/checkTypes.js";
declare global {
interface Window {
__hyperframesLayoutAudit(options: { time: number; tolerance: number }): { code: string }[];
__hyperframesCraftSample(): Omit<CraftFrame, "time">;
__hyperframesCraftFilm(): CraftFilm;
}
}
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
const script = readFileSync(new URL("./layout-audit.browser.js", import.meta.url), "utf8");
const signatureScript = readFileSync(
new URL("./motion-signature.browser.js", import.meta.url),
"utf8",
);
const stage = (body: string) =>
`<div data-composition-id="main" data-width="1920" data-height="1080" style="position:relative;width:1920px;height:1080px;background:#0b0d12;color:#fff;font-family:Arial">${body}</div>`;
const heading = (top: number, text: string, style = "") =>
`<h1 style="position:absolute;left:100px;top:${top}px;margin:0;font:120px/1 Arial;${style}">${text}</h1>`;
@@ -238,4 +248,74 @@ describe.runIf(executablePath)("layout audit in Chromium", () => {
<div data-composition-id="main" data-width="1920" data-height="1080" style="position:relative;width:1920px;height:1080px">${html}</div>`);
expect(codes.includes("content_overlap")).toBe(overlap);
});
describe("craft sampling", () => {
async function sample(body: string) {
const page = await browser.newPage();
try {
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
await page.setContent(`<body style="margin:0">${stage(body)}</body>`);
await page.addScriptTag({ content: signatureScript });
await page.addScriptTag({ content: script });
return await page.evaluate(() => ({
frame: window.__hyperframesCraftSample(),
again: window.__hyperframesCraftSample(),
film: window.__hyperframesCraftFilm(),
}));
} finally {
await page.close();
}
}
it("reads a headline split into inline word spans as one reading block", async () => {
const { frame } = await sample(
`<h1 id="title" style="position:absolute;left:100px;top:300px;margin:0;font-size:96px"><span style="display:inline-block">Ship</span> <span style="display:inline-block">it</span> <span style="display:inline-block">faster</span></h1>`,
);
expect(frame.parts.map((part) => part.text)).toEqual(["Ship", "it", "faster"]);
expect(new Set(frame.parts.map((part) => part.block)).size).toBe(1);
expect(frame.parts[0]?.blockSelector).toBe("#title");
});
it("flags captions-track text and leaves out data-layout-ignore text", async () => {
const { frame } = await sample(
`<div data-track-kind="captions" style="position:absolute;bottom:80px;left:0;font-size:40px"><span>caption word</span></div>
<div data-layout-ignore style="position:absolute;top:40px;left:40px;font-size:12px">texture</div>
<p style="position:absolute;top:500px;left:100px;margin:0;font-size:48px">Reading line</p>`,
);
expect(frame.parts.map((part) => [part.text, part.captions])).toEqual([
["caption word", true],
["Reading line", false],
]);
});
it("measures the rendered font size through an ancestor scale", async () => {
const { frame } = await sample(
`<div style="position:absolute;left:100px;top:100px;transform:scale(0.5);transform-origin:0 0"><p style="margin:0;font-size:40px">Scaled label</p></div>`,
);
expect(frame.parts[0]?.fontPx).toBeCloseTo(20, 0);
});
it("reports an empty frame, a held element and a stable signature", async () => {
const empty = await sample(
`<h1 style="position:absolute;top:300px;opacity:0;font-size:96px">Not yet</h1><div style="position:absolute;inset:0;background:#111"></div>`,
);
expect(empty.frame.painted).toBe(false);
expect(empty.frame.held).toBe(false);
expect(empty.frame.signature).not.toBe("");
expect(empty.again.signature).toBe(empty.frame.signature);
const held = await sample(
`<section data-hold style="position:absolute;inset:0"><svg width="200" height="200"><circle cx="100" cy="100" r="80" fill="#7dd3fc"/></svg></section>`,
);
expect(held.frame.painted).toBe(true);
expect(held.frame.held).toBe(true);
});
it("counts the sound sources the render mixes", async () => {
const { film } = await sample(
`<audio src="music.mp3"></audio><video src="a.mp4"></video><video src="b.mp4" muted></video><video src="c.mp4" data-has-audio="false"></video>`,
);
expect(film.soundSources).toBe(2);
});
});
});
+4 -1
View File
@@ -389,7 +389,10 @@ async function runMotionPass(
await page.addScriptTag({ content: loadBrowserScript("motion-signature.browser.js") });
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
const frames = await collectMotionFrames(page, times, selectors, livenessScopes);
return { issues: evaluateMotion(frames, spec.assertions, canvas), sampleCount: frames.length };
return {
issues: evaluateMotion(frames, spec.assertions, canvas, duration),
sampleCount: frames.length,
};
}
/** Read + validate the motion sidecar; print the error and exit on a bad spec. */
+2 -2
View File
@@ -184,7 +184,7 @@ export default defineCommand({
skill: {
type: "string",
description:
"Authoring workflow skill that initiated this render (e.g. product-launch-video). " +
"Authoring skill that initiated this render (e.g. hyperframes). " +
"Recorded on anonymous render telemetry for per-skill usage breakdowns; ignored unless it is a slug.",
},
format: {
@@ -1941,7 +1941,7 @@ function readOutputFootprint(outputPath: string): { fileSize: string; isDirector
}
}
/** A render points to the desktop app; a batch row does not. Drafts do too: music-to-video delivers one. */
/** A render points to the desktop app; a batch row does not. Drafts do too. */
export function wantsDesktopHint(options: Pick<RenderOptions, "desktopHint">): boolean {
return options.desktopHint !== false;
}
+87 -34
View File
@@ -80,8 +80,8 @@ vi.mock("../telemetry/events.js", () => ({
// A realistic check result for the targeted paths (`update`, with or without names):
// one core skill outdated, one core missing, one on-demand workflow installed
// and current, one on-demand workflow not installed. `update` must refresh the
// two stale core skills, leave `pr-to-video` alone (on demand), and keep
// `embedded-captions` (installed + current) untouched.
// two stale core skills, leave `slideshow` alone (on demand), and keep
// `figma` (installed + current) untouched.
const DEFAULT_CHECK = {
location: "/home/user/.claude/skills",
agent: "claude-code",
@@ -90,9 +90,9 @@ const DEFAULT_CHECK = {
summary: { current: 1, outdated: 1, missing: 2, coreMissing: 1, removed: 0 },
skills: [
{ name: "hyperframes", status: "outdated" },
{ name: "hyperframes-core", status: "missing" },
{ name: "embedded-captions", status: "current" },
{ name: "pr-to-video", status: "missing" },
{ name: "hyperframes-registry", status: "missing" },
{ name: "figma", status: "current" },
{ name: "slideshow", status: "missing" },
],
lockMissing: false,
};
@@ -242,9 +242,9 @@ describe("hyperframes skills", () => {
"--skill",
"hyperframes",
"--skill",
"hyperframes-core",
"hyperframes-registry",
"--skill",
"pr-to-video",
"slideshow",
...GLOBAL_ARGS_TAIL,
],
],
@@ -259,9 +259,9 @@ describe("hyperframes skills", () => {
"--skill",
"hyperframes",
"--skill",
"hyperframes-core",
"hyperframes-registry",
"--skill",
"pr-to-video",
"slideshow",
...GLOBAL_ARGS_TAIL,
],
],
@@ -280,9 +280,9 @@ describe("hyperframes skills", () => {
"--skill",
"hyperframes",
"--skill",
"hyperframes-core",
"hyperframes-registry",
"--skill",
"pr-to-video",
"slideshow",
...GLOBAL_ARGS_TAIL,
],
],
@@ -322,12 +322,12 @@ describe("hyperframes skills", () => {
expect(args).toContain("--copy");
expect(args).toContain("--full-depth");
// targeted per-name selection: the stale core skills only
expect(skillFlagValues(args).sort()).toEqual(["hyperframes", "hyperframes-core"]);
expect(skillFlagValues(args).sort()).toEqual(["hyperframes", "hyperframes-registry"]);
// never the full-set wildcard, and never a missing on-demand workflow
expect(skillFlagValues(args)).not.toContain("*");
expect(skillFlagValues(args)).not.toContain("pr-to-video");
expect(skillFlagValues(args)).not.toContain("slideshow");
// installed-and-current skills are not re-fetched
expect(skillFlagValues(args)).not.toContain("embedded-captions");
expect(skillFlagValues(args)).not.toContain("figma");
// never the `--all` (= `--agent '*'`) spray
expect(args).not.toContain("--all");
// `--agent` must be followed by a concrete key, never the `'*'` wildcard
@@ -342,15 +342,15 @@ describe("hyperframes skills", () => {
...DEFAULT_CHECK,
skills: [
{ name: "hyperframes", status: "current" },
{ name: "embedded-captions", status: "outdated" }, // installed workflow → refresh
{ name: "pr-to-video", status: "missing" }, // not installed → leave for on-demand
{ name: "figma", status: "outdated" }, // installed workflow → refresh
{ name: "slideshow", status: "missing" }, // not installed → leave for on-demand
],
} as never);
await runSkillsUpdate();
const args = state.spawnCalls[0]?.args ?? [];
expect(skillFlagValues(args)).toEqual(["embedded-captions"]);
expect(skillFlagValues(args)).toEqual(["figma"]);
});
it("skills update is a no-install no-op when everything is current", async () => {
@@ -361,7 +361,7 @@ describe("hyperframes skills", () => {
updateAvailable: false,
skills: [
{ name: "hyperframes", status: "current" },
{ name: "pr-to-video", status: "missing" }, // on demand — not an update
{ name: "slideshow", status: "missing" }, // on demand — not an update
],
} as never);
@@ -396,6 +396,25 @@ describe("hyperframes skills", () => {
expect(await commandExitCode()).toBe(0);
});
it("skills update removes every retired skill check reports, in one call", async () => {
setPlatform("linux");
const { checkSkills, RETIRED_SKILLS } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills)
.mockResolvedValueOnce(DEFAULT_CHECK as never)
.mockResolvedValueOnce({
scope: "global",
skills: RETIRED_SKILLS.map((name) => ({ name, status: "removed" })),
} as never);
await runSkillsUpdate();
const removeCalls = state.spawnCalls.filter((s) => s.args.includes("remove"));
expect(removeCalls).toHaveLength(1);
for (const name of RETIRED_SKILLS) expect(removeCalls[0]!.args).toContain(name);
expect(removeCalls[0]!.args).toContain("-g");
expect(await commandExitCode()).toBe(0);
});
// The scope the skill was attributed from drives the remove scope: a
// project-scoped removal must NOT pass -g (which would target a different,
// possibly user-owned, global skill of the same name).
@@ -813,17 +832,17 @@ describe("hyperframes skills update <names>", () => {
it("installs the requested workflow plus the stale core set — nothing else", async () => {
setPlatform("linux");
await runSkillsUpdateWith(["pr-to-video"]);
await runSkillsUpdateWith(["slideshow"]);
expect(await commandExitCode()).toBe(0);
const args = state.spawnCalls[0]?.args ?? [];
expect(args).toContain("add");
// requested workflow (missing) + the stale core skills; embedded-captions
// requested workflow (missing) + the stale core skills; figma
// (installed + current) and the full-set wildcard must not appear.
expect(skillFlagValues(args).sort()).toEqual([
"hyperframes",
"hyperframes-core",
"pr-to-video",
"hyperframes-registry",
"slideshow",
]);
});
@@ -835,17 +854,51 @@ describe("hyperframes skills update <names>", () => {
updateAvailable: false,
skills: [
{ name: "hyperframes", status: "current" },
{ name: "hyperframes-core", status: "current" },
{ name: "pr-to-video", status: "current" },
{ name: "hyperframes-registry", status: "current" },
{ name: "slideshow", status: "current" },
],
} as never);
await runSkillsUpdateWith(["pr-to-video"]);
await runSkillsUpdateWith(["slideshow"]);
expect(state.spawnCalls).toHaveLength(0);
expect(await commandExitCode()).toBe(0);
});
it("a retired workflow name installs nothing, says where its knowledge went, and succeeds", async () => {
setPlatform("linux");
const logSpy = vi.spyOn(console, "log");
await runSkillsUpdateWith(["product-launch-video"]);
expect(await commandExitCode()).toBe(0);
// The core set that now carries the knowledge is still refreshed; the
// retired name never reaches `skills add`.
const args = state.spawnCalls[0]?.args ?? [];
expect(skillFlagValues(args).sort()).toEqual(["hyperframes", "hyperframes-registry"]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(printed).toMatch(/product-launch-video was retired/);
expect(printed).toMatch(/\/hyperframes skill and its references/);
expect(printed).not.toMatch(/Ready: product-launch-video/);
});
it("--json reports a retired name under retired, not unknown", async () => {
setPlatform("linux");
const logSpy = vi.spyOn(console, "log");
await runSkillsUpdateWith(["music-to-video", "slideshow"], { json: true });
expect(await commandExitCode()).toBe(0);
const last = String(logSpy.mock.calls.at(-1)?.[0] ?? "");
const parsed = JSON.parse(last) as {
retired?: string[];
unknown?: string[];
installed?: string[];
};
expect(parsed.retired).toEqual(["music-to-video"]);
expect(parsed.unknown).toEqual([]);
expect(parsed.installed).toContain("slideshow");
expect(parsed.installed).not.toContain("music-to-video");
});
it("fails loudly on a skill name the manifest doesn't ship", async () => {
setPlatform("linux");
await runSkillsUpdateWith(["graphic-overlays"]); // renamed upstream → unknown
@@ -867,7 +920,7 @@ describe("hyperframes skills update <names>", () => {
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockRejectedValue(new Error("offline"));
await runSkillsUpdateWith(["pr-to-video"]);
await runSkillsUpdateWith(["slideshow"]);
expect(state.spawnCalls).toHaveLength(0);
expect(await commandExitCode()).toBe(0);
@@ -883,12 +936,12 @@ describe("hyperframes skills update <names>", () => {
.mockImplementationOnce(() => [])
.mockImplementation((names: readonly string[]) => [...names]);
await runSkillsUpdateWith(["pr-to-video"]);
await runSkillsUpdateWith(["slideshow"]);
// The offline guarantee must still cover the core tier the workflow
// depends on, not silently shrink to just the named skill.
const args = state.spawnCalls[0]?.args ?? [];
expect(skillFlagValues(args).sort()).toEqual(["pr-to-video", ...FALLBACK_CORE_SKILLS].sort());
expect(skillFlagValues(args).sort()).toEqual(["slideshow", ...FALLBACK_CORE_SKILLS].sort());
expect(await commandExitCode()).toBe(0);
});
@@ -939,7 +992,7 @@ describe("hyperframes skills update <names>", () => {
new Error("Malformed skills manifest from https://raw.githubusercontent.com/…"),
);
await runSkillsUpdateWith(["pr-to-video"]);
await runSkillsUpdateWith(["slideshow"]);
const warnedMalformed = vi
.mocked(clack.log.warn)
@@ -956,7 +1009,7 @@ describe("hyperframes skills update <names>", () => {
const { checkSkills } = await import("../utils/skillsManifest.js");
vi.mocked(checkSkills).mockRejectedValue(new Error("fetch failed"));
await runSkillsUpdateWith(["pr-to-video"]);
await runSkillsUpdateWith(["slideshow"]);
const warnedMalformed = vi
.mocked(clack.log.warn)
@@ -968,14 +1021,14 @@ describe("hyperframes skills update <names>", () => {
setPlatform("linux");
const logSpy = vi.spyOn(console, "log");
await runSkillsUpdateWith(["pr-to-video"], { json: true });
await runSkillsUpdateWith(["slideshow"], { json: true });
expect(await commandExitCode()).toBe(0);
// The engine logs install progress lines too; the JSON result is the last
// console.log of the run (the prune prints nothing when nothing was removed).
const last = String(logSpy.mock.calls.at(-1)?.[0] ?? "");
const parsed = JSON.parse(last) as { installed?: string[] };
expect(parsed.installed).toContain("pr-to-video");
expect(parsed.installed).toContain("slideshow");
});
it("--json emits a parseable error object on failure", async () => {
@@ -994,7 +1047,7 @@ describe("hyperframes skills update <names>", () => {
setPlatform("linux");
state.spawnExitCode = 1;
await runSkillsUpdateWith(["pr-to-video"]);
await runSkillsUpdateWith(["slideshow"]);
expect(await commandExitCode()).toBe(1);
});
@@ -1005,7 +1058,7 @@ describe("hyperframes skills update <names>", () => {
// The install claims success but delivers nothing.
vi.mocked(presentSkills).mockImplementation(() => []);
await runSkillsUpdateWith(["pr-to-video"]);
await runSkillsUpdateWith(["slideshow"]);
expect(state.spawnCalls[0]?.args).toContain("add");
expect(await commandExitCode()).toBe(1);
+33 -26
View File
@@ -13,6 +13,8 @@ import {
isCoreSkill,
presentSkills,
pruneOrphanedLockEntries,
RETIRED_SKILLS,
retiredSkillNotice,
SKILLS_CLI_LOCK_PATHS_VERIFIED_AT,
type SkillDiff,
type SkillsCheckResult,
@@ -27,7 +29,7 @@ export const examples: Example[] = [
["Check whether installed skills are up to date", "hyperframes skills check"],
["Check, machine-readable (for agents / CI)", "hyperframes skills check --json"],
["Update the core set + everything already installed", "hyperframes skills update"],
["Also install one workflow (on-demand install)", "hyperframes skills update pr-to-video"],
["Also install one on-demand skill", "hyperframes skills update slideshow"],
];
function hasNpx(): boolean {
@@ -299,6 +301,8 @@ export interface UpdateSkillsResult {
unknown: string[];
/** True when freshness couldn't be checked (offline) and only presence was verified. */
presenceOnly: boolean;
/** Requested names that are retired: nothing to install, their knowledge lives in the core set. */
retired: string[];
}
/**
@@ -306,8 +310,9 @@ export interface UpdateSkillsResult {
* the replacement for "anything stale ⇒ re-pull the full skill set". It
* guarantees a small, explicit set is installed and current:
*
* - the requested names (a workflow being routed to, e.g. `pr-to-video`),
* - the core set (entry router + shared domain skills — see skillsManifest),
* - the requested names (an on-demand skill, e.g. `slideshow`; a retired
* name is reported, not installed),
* - the core set (entry skill + shared skills — see skillsManifest),
* - with `refreshInstalled`, whatever is already installed (refreshed, so an
* update never *expands* a deliberate partial install),
* - with `all`, every skill the manifest publishes. The upstream `*` wildcard
@@ -332,7 +337,9 @@ export async function updateSkills(
cwd?: string;
} = {},
): Promise<UpdateSkillsResult> {
const requested = [...new Set(opts.requested ?? [])];
const named = [...new Set(opts.requested ?? [])];
const retired = named.filter((name) => RETIRED_SKILLS.includes(name));
const requested = named.filter((name) => !retired.includes(name));
const strict = opts.strict ?? false;
let check: SkillsCheckResult | null = null;
@@ -373,7 +380,7 @@ export async function updateSkills(
),
);
}
return updateSkillsOffline(requested, { strict, cwd: opts.cwd });
return { ...(await updateSkillsOffline(requested, { strict, cwd: opts.cwd })), retired };
}
// "removed" entries are lock-attributed leftovers, not manifest skills —
@@ -404,6 +411,7 @@ export async function updateSkills(
current: targets.filter((s) => s.status === "current").map((s) => s.name),
unknown,
presenceOnly: false,
retired,
};
if (toInstall.length > 0) {
@@ -424,10 +432,10 @@ export async function updateSkills(
* The presence half of the guarantee, after an install claims success: every
* name must now exist on disk. Catches the "install exited 0 but delivered
* nothing" failure mode, which would otherwise surface much later as a
* workflow reading skill files that aren't there.
* skill reading files that aren't there.
*
* Strictness mirrors the caller's tolerance: a strict run (the `check ||
* update` CI contract, the router's trigger-time guarantee) throws so the
* update` CI contract) throws so the
* failure is loud; a non-strict run (init) only warns and proceeds, since a
* skills hiccup must never break scaffolding.
*/
@@ -444,13 +452,13 @@ function verifyInstalled(names: readonly string[], opts: { strict: boolean; cwd?
* Offline degradation for updateSkills: the manifest is unreachable, so
* freshness is unknowable. What can still be honored is the PRESENCE half of
* the guarantee, extended to the pinned FALLBACK_CORE_SKILLS list so the
* router / preamble promise ("this workflow plus the core set it depends on")
* preamble promise ("this skill plus the core set it depends on")
* doesn't silently shrink to just the named skill on degraded networks —
* raw.githubusercontent.com blocked while `git clone` works is a real
* corporate-proxy shape, which is exactly when the blind install below can
* still succeed.
*
* - Named run (`update <workflow>`): presence-check requested + fallback
* - Named run (`update <name>`): presence-check requested + fallback
* core, blind-install whatever is absent (a truly dead network fails the
* clone fast, and `strict` decides how loudly). Stale-but-present
* proceeds — blocking a build on a network hiccup is worse than running
@@ -464,7 +472,7 @@ function verifyInstalled(names: readonly string[], opts: { strict: boolean; cwd?
async function updateSkillsOffline(
requested: readonly string[],
opts: { strict: boolean; cwd?: string },
): Promise<UpdateSkillsResult> {
): Promise<Omit<UpdateSkillsResult, "retired">> {
if (requested.length === 0 && opts.strict) {
throw new Error(
"can't check skills freshness (GitHub manifest unreachable) — refusing to report success. Retry when online.",
@@ -550,7 +558,7 @@ function renderCheck(result: SkillsCheckResult): void {
printSkillSection(
result,
"missing",
"Available on demand (installed when their workflow first runs):",
"Available on demand (install with `hyperframes skills update <name>`):",
"◦",
c.dim,
(s) => !isCoreSkill(s.name),
@@ -674,6 +682,7 @@ function reportUpdate(
console.log(JSON.stringify(withMeta(result), null, 2));
return;
}
for (const name of result.retired) console.log(c.warn(retiredSkillNotice(name)));
if (result.installed.length > 0) {
console.log(
c.success(
@@ -687,9 +696,10 @@ function reportUpdate(
} else {
console.log(c.success("Installed skills are already up to date."));
}
// The named skills are the caller's actual question ("is my workflow ready?")
// The named skills are the caller's actual question ("is my skill ready?")
// — answer it explicitly, whatever the install had to do.
if (requested.length) console.log(c.success(`◇ Ready: ${requested.join(", ")}`));
const ready = requested.filter((name) => !result.retired.includes(name));
if (ready.length) console.log(c.success(`◇ Ready: ${ready.join(", ")}`));
}
/**
@@ -709,7 +719,7 @@ const updateCommand = defineCommand({
meta: {
name: "update",
description:
"Update the core set plus every installed HyperFrames skill to the latest, and remove any no longer published. Pass skill names to also install those (how workflow skills install on demand) — without names it never expands a partial install",
"Update the core set plus every installed HyperFrames skill to the latest, and remove any no longer published. Pass skill names to also install those (how on-demand skills install) — without names it never expands a partial install",
},
// Mirror `check`'s flags: the prune step runs the same removed-detection, so it
// must respect the same overrides. Without these, `update`'s internal
@@ -732,11 +742,10 @@ const updateCommand = defineCommand({
const dir = args.dir;
const source = args.source;
// Positional skill names (e.g. `hyperframes skills update pr-to-video`) are
// Positional skill names (e.g. `hyperframes skills update slideshow`) are
// the ONLY way update expands an install: each named skill is guaranteed
// present and current. This is the router's trigger-time step — the
// /hyperframes router runs it after picking a workflow, before reading the
// workflow's skill.
// present and current. A retired name installs nothing; the run says where
// its knowledge went and still refreshes the core set that carries it.
const { requested, rejected } = requestedNamesFrom(args._ ?? []);
if (rejected.length) {
reportUpdateFailure(`Invalid skill name(s): ${rejected.join(", ")}`, args.json === true);
@@ -745,18 +754,17 @@ const updateCommand = defineCommand({
}
// Exit 0 without installing, pruning or touching the nudge cache: the pinned
// install is the one the caller wants, so the router's step keeps working.
// install is the one the caller wants, so the skill's step keeps working.
if (skillsManagedExternally()) {
reportSkillsManagedExternally(requested, args.json === true);
return;
}
// Targeted, not full-set: refresh the core set (entry router + shared
// Targeted, not full-set: refresh the core set (entry skill + shared
// domain skills) plus whatever is already installed, plus anything named
// above. Without names a deliberate partial install stays partial
// (refreshed, but never expanded) — the end-user workflow skills install
// on demand, when their workflow is
// triggered. This is where `init` and the stale-skills nudge both lead;
// (refreshed, but never expanded) — the on-demand skills install when
// named. This is where `init` and the stale-skills nudge both lead;
// pulling the complete skill set here is exactly what users complained
// about. Explicit full set: `hyperframes skills` or `npx skills add
// heygen-com/hyperframes --all`.
@@ -768,8 +776,7 @@ const updateCommand = defineCommand({
// HyperFrames repo so `update` reliably refreshes the published skills.
//
// strict: this is the documented recovery path for the agent/CI contract
// `hyperframes skills check || hyperframes skills update`, and the router's
// trigger-time guarantee. If the install fails (no npx, `skills add` exits
// `hyperframes skills check || hyperframes skills update`. If the install fails (no npx, `skills add` exits
// non-zero, a named skill still absent afterwards) it must exit non-zero
// too — otherwise the `||` chain passes while nothing actually changed.
try {
@@ -782,7 +789,7 @@ const updateCommand = defineCommand({
}
// `skills add` never deletes, so a skill renamed or dropped upstream
// (e.g. graphic-overlays → talking-head-recut) would linger forever. Prune
// (e.g. a retired workflow skill) would linger forever. Prune
// skills the lock attributes to our source that the manifest no longer
// ships, so `check || update` fully reconciles the install to the manifest.
//
+261 -62
View File
@@ -18,7 +18,7 @@ import {
} from "@hyperframes/studio-server/history";
import { pngDimensions } from "../utils/pngDimensions.js";
import { runWithConcurrencyLimit } from "../utils/concurrency.js";
import { failCommand } from "../utils/commandResult.js";
import { CliRuntimeError, failCommand } from "../utils/commandResult.js";
// fallow-ignore-file complexity
import { defineCommand } from "citty";
import { existsSync, mkdtempSync, readFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
@@ -59,6 +59,17 @@ import { parseAngle, type Camera } from "./motionShotLayout.js";
import type { Example } from "./_examples.js";
import { loadInstalledSharp, loadOptionalPackage } from "../utils/optionalPackages.js";
import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js";
import {
REVIEW_PHONE_WIDTH,
planReview,
resolveReviewTimeline,
reviewFrames,
writeReviewSheets,
type ReviewFrame,
type ReviewPlan,
type ReviewSheets,
} from "./snapshotReview.js";
import { withMeta } from "../utils/updateCheck.js";
// Runs IN THE BROWSER (serialized into page.evaluate). Tilt the whole stage so
// the REAL painted pixels are viewed from an orthogonal angle (FINDING [10]:
@@ -502,8 +513,13 @@ async function captureSnapshots(
against?: string;
/** Add to outputDir without clearing it; name each still by its time, not its place in the run. */
keep?: boolean;
/** Capture the review set (beats, seams) instead of evenly spaced frames. */
review?: boolean;
/** Progress lines; `--json` sends them to stderr so stdout stays one envelope. */
log?: (message: string) => void;
},
): Promise<string[]> {
): Promise<CapturedSnapshots> {
const log = opts.log ?? ((message: string) => console.log(message));
const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js");
const capture = await captureArtifactRevision(projectDir).catch(warnSnapshotsNotRecorded);
@@ -518,6 +534,9 @@ async function captureSnapshots(
const warnings: string[] = [];
let completed = false;
const numFrames = opts.frames ?? 5;
let review: ReviewPlan | undefined;
let reviewList: ReviewFrame[] | undefined;
const reviewPaths = new Map<ReviewFrame, string>();
// Localize fonts (embed remote @font-face as data URIs, matching the render
// path) so snapshots render the real font instead of a fallback sans.
@@ -567,9 +586,9 @@ async function captureSnapshots(
const parts = [`${fontReport.loaded.length} loaded`];
if (fontReport.errored.length > 0) parts.push(`${fontReport.errored.length} failed`);
if (fontReport.unused.length > 0) parts.push(`${fontReport.unused.length} unused`);
console.log(`\n ${c.dim("Fonts:")} ${parts.join(", ")}`);
log(`\n ${c.dim("Fonts:")} ${parts.join(", ")}`);
if (fontReport.errored.length > 0) {
console.log(` ${c.error("Fonts FAILED:")} ${fontReport.errored.join(", ")}`);
log(` ${c.error("Fonts FAILED:")} ${fontReport.errored.join(", ")}`);
}
}
@@ -588,12 +607,11 @@ async function captureSnapshots(
});
if (duration <= 0 && !opts.at?.length) {
return [];
return { paths: [] };
}
let range = opts.range;
if (opts.composition) {
const timing = await page.evaluate(() => ({
const readTiming = () =>
page.evaluate(() => ({
html: document.documentElement.outerHTML,
durations: Object.fromEntries(
Object.entries((window as any).__timelines ?? {}).map(([id, timeline]) => [
@@ -602,6 +620,9 @@ async function captureSnapshots(
]),
),
}));
let range = opts.range;
if (opts.composition && !opts.review) {
const timing = await readTiming();
range = resolveSnapshotCompositionRange(
timing.html,
timing.durations,
@@ -609,22 +630,40 @@ async function captureSnapshots(
duration,
);
}
const { times: positions, appendedTail } = range
? {
times: sampleSnapshotRange(range, numFrames, duration, !!opts.composition),
appendedTail: false,
}
: computeSnapshotTimes(duration, {
frames: numFrames,
at: opts.at,
includeEnd: opts.includeEnd,
});
if (opts.review) {
const timing = await readTiming();
const timeline = resolveReviewTimeline(
timing.html,
timing.durations,
duration,
opts.composition,
);
// The render's last frame is one 30fps frame before the end, where clips are still mounted.
review = planReview(timeline, Math.min(timeline.window[1], duration) - 1 / 30);
reviewList = reviewFrames(review);
}
const { times: positions, appendedTail } = reviewList
? { times: reviewList.map((frame) => frame.time), appendedTail: false }
: range
? {
times: sampleSnapshotRange(range, numFrames, duration, !!opts.composition),
appendedTail: false,
}
: computeSnapshotTimes(duration, {
frames: numFrames,
at: opts.at,
includeEnd: opts.includeEnd,
});
if (range)
console.log(
log(
` Sampling ${positions.length} frames within ${range[0]}–${range[1]}s${opts.composition ? ` (composition ${opts.composition})` : ""}`,
);
if (review)
log(
` Review: ${review.beats.length} beat(s) and ${review.seams.length} seam(s) within ${review.window[0]}–${review.window[1]}s${opts.composition ? ` (composition ${opts.composition})` : ""}`,
);
if (appendedTail) {
console.log(
log(
` ${c.dim(`Note: added an end-of-timeline frame at ${positions[positions.length - 1]!.toFixed(2)}s. Short beats between your --at times may still be skipped — pass them explicitly.`)}`,
);
}
@@ -859,7 +898,12 @@ async function captureSnapshots(
const slot = opts.keep
? `at-${timeLabel}`
: `${String(i).padStart(2, "0")}-at-${timeLabel}`;
const filename = opts.keep ? `${slot}.png` : `frame-${slot}.png`;
const reviewFrame = reviewList?.[i];
const filename = reviewFrame
? reviewFrame.file
: opts.keep
? `${slot}.png`
: `frame-${slot}.png`;
const framePath = join(snapshotDir, filename);
let crop: { x: number; y: number; width: number; height: number } | undefined;
@@ -902,8 +946,12 @@ async function captureSnapshots(
zoomScale: opts.zoom ? (opts.zoomScale ?? DEFAULT_ZOOM_SCALE) : 1,
angle: opts.angle,
browserGpuMode: opts.browserGpuMode,
...(reviewFrame
? { review: { role: reviewFrame.role, label: reviewFrame.label } }
: {}),
},
});
if (reviewFrame) reviewPaths.set(reviewFrame, framePath);
if (opts.against) {
// Frame-exact reference frame beside the render, so a rebuild can be
// checked against its footage without hand-rolled ffmpeg + montage.
@@ -946,6 +994,7 @@ async function captureSnapshots(
opts.zoom,
warnings,
completed,
log,
)
: false;
if (!recorded) {
@@ -954,7 +1003,26 @@ async function captureSnapshots(
}
}
return savedPaths;
return {
paths: savedPaths,
...(review && reviewList
? {
review: {
plan: review,
frames: reviewList.flatMap((frame) => {
const path = reviewPaths.get(frame);
return path ? [{ ...frame, path }] : [];
}),
},
}
: {}),
};
}
interface CapturedSnapshots {
/** Project-relative where possible, as printed. */
paths: string[];
review?: { plan: ReviewPlan; frames: Array<ReviewFrame & { path: string }> };
}
function warnSnapshotsNotRecorded(error: unknown): undefined {
@@ -969,6 +1037,7 @@ async function recordSnapshotArtifacts(
zoom: ZoomTarget | undefined,
warnings: string[],
completed: boolean,
log: (message: string) => void,
): Promise<boolean> {
const records = await recordArtifacts(
projectDir,
@@ -982,11 +1051,85 @@ async function recordSnapshotArtifacts(
).catch(warnSnapshotsNotRecorded);
const record = records?.[0];
if (!record) return false;
console.log(` Revision: ${record.revision ?? "none"} (${record.attribution})`);
console.log(` State: ${join(projectDir, ".hyperframes", "video-state.json")}`);
log(` Revision: ${record.revision ?? "none"} (${record.attribution})`);
log(` State: ${join(projectDir, ".hyperframes", "video-state.json")}`);
return true;
}
/** `snapshot --json`: every still with its time, the sheets, and under --review the plan behind them. */
function snapshotEnvelope(
projectDir: string,
outputDir: string,
captured: CapturedSnapshots,
extra: { contactSheets: string[]; reviewSheets?: ReviewSheets; describedPath?: string },
) {
const review = captured.review;
const frames = review
? review.frames.map((frame) => ({
path: frame.path,
time: frame.time,
role: frame.role,
label: frame.label,
}))
: captured.paths.map((path) => ({ path: resolve(projectDir, path), time: timeFromName(path) }));
return {
ok: true,
outputDir,
frames,
contactSheets: extra.contactSheets.map((sheet) => resolve(sheet)),
...(extra.describedPath ? { descriptions: extra.describedPath } : {}),
...(review
? {
review: {
window: review.plan.window,
scenes: review.plan.scenes,
beats: review.plan.beats.map((beat, index) => ({
...beat,
path: review.frames.find(
(frame) => frame.role === "beat" && frame.index === index + 1,
)?.path,
})),
seams: review.plan.seams.map((seam, index) => ({
...seam,
paths: review.frames
.filter((frame) => frame.role === "seam" && frame.index === index + 1)
.map((frame) => frame.path),
sheet: extra.reviewSheets?.seams[index],
})),
sheets: extra.reviewSheets ?? { beats: [], seams: [], phone: [] },
notes: review.plan.notes,
},
}
: {}),
};
}
function timeFromName(path: string): number | null {
const match = /at-([\d.]+)s\.png$/.exec(path);
return match ? Number(match[1]) : null;
}
function printReview(
review: NonNullable<CapturedSnapshots["review"]>,
sheets: ReviewSheets,
log: (message: string) => void,
): void {
const beats = review.plan.beats.map(
(beat) => `${formatSnapshotTimestamp(beat.time)} ${beat.label}`,
);
log(` Beat sheet (settled frame per scene): ${sheets.beats.join(", ") || "none"}`);
log(` ${c.dim(beats.join(" · "))}`);
review.plan.seams.forEach((seam, index) => {
const sides = `${seam.from ?? "start"} -> ${seam.to ?? "end"}`;
const strip = sheets.seams[index] ?? "not written";
log(` Seam ${index + 1} at ${formatSnapshotTimestamp(seam.time)} (${sides}): ${strip}`);
});
log(
` Phone sheet (beats at ${REVIEW_PHONE_WIDTH} px wide): ${sheets.phone.join(", ") || "none"}`,
);
for (const note of review.plan.notes) log(` ${c.dim(note)}`);
}
export default defineCommand({
meta: {
name: "snapshot",
@@ -1061,6 +1204,17 @@ export default defineCommand({
description:
"Reference video (e.g. footage being rebuilt): also save its frame at each captured time and a render|reference pair sheet",
},
review: {
type: "boolean",
description:
"Capture the review set instead of evenly spaced frames: each scene at its start + 0.3 s and its middle, and each data-beat at its start + 0.3 s, on review-beats.jpg, a 5-frame strip around every cut (review-seam-NN.jpg), and the beat frames at phone width (review-phone.jpg). Scopes to --composition; ignores --frames.",
default: false,
},
json: {
type: "boolean",
description: "Print one JSON envelope (paths, times, sheets; the review plan under --review)",
default: false,
},
describe: {
type: "string",
description:
@@ -1080,8 +1234,22 @@ export default defineCommand({
},
},
async run({ args }) {
const asJson = args.json === true;
const log = (message: string) => (asJson ? console.error(message) : console.log(message));
const fail = (message: string): never => {
if (asJson) console.log(JSON.stringify(withMeta({ ok: false, error: message }), null, 2));
else log(message);
failCommand();
};
if ([args.at, args.range, args.composition].filter((value) => value !== undefined).length > 1)
throw new Error("Use only one of --at, --range, or --composition");
const reviewMode = args.review === true;
if (reviewMode) {
const clash = (["at", "range", "zoom", "keep", "against"] as const).find(
(flag) => args[flag] !== undefined && args[flag] !== false,
);
if (clash) throw new Error(`--review picks its own frames; drop --${clash}`);
}
const frames = parseInt(args.frames as string, 10) || 5;
const range = args.range === undefined ? undefined : parseSnapshotRange(String(args.range));
const composition =
@@ -1091,28 +1259,32 @@ export default defineCommand({
const lintResult = await lintProject(project.dir);
if (hasDefinitiveEntryMismatch(lintResult)) {
const candidate = definitiveEntryMismatchComposition(lintResult);
console.log("");
log("");
for (const line of formatLintFindings(lintResult, { errorsFirst: true })) {
console.log(line);
log(line);
}
console.log("");
console.log(c.error(" Aborting snapshot because the default index.html entry is blank."));
log("");
log(c.error(" Aborting snapshot because the default index.html entry is blank."));
if (candidate && posix.basename(candidate) === "index.html") {
const candidateDir = posix.dirname(candidate);
const target = `<project>/${candidateDir}`;
console.log(
log(
c.dim(
` Move or mount the authored file, or snapshot its directory directly: hyperframes snapshot ${target}. Only use the directory form when its assets are self-contained under that directory; otherwise mount it from the project root.`,
),
);
} else if (candidate) {
console.log(
log(
c.dim(
` Move or mount ${candidate} as a project index.html before snapshotting; snapshot accepts project directories, not individual HTML files.`,
),
);
}
console.log("");
log("");
if (asJson) {
const error = "The default index.html entry is blank; see the lint findings on stderr.";
console.log(JSON.stringify(withMeta({ ok: false, error }), null, 2));
}
failCommand();
}
const timeout = parseInt(args.timeout as string, 10) || 5000;
@@ -1138,19 +1310,19 @@ export default defineCommand({
const zoomTarget = args.zoom ? parseZoomTarget(String(args.zoom)) : undefined;
const zoomScale = parseZoomScale(args["zoom-scale"]);
const against = args.against ? resolve(String(args.against)) : undefined;
if (against && !existsSync(against)) {
console.log(`${c.error("✗")} --against video not found: ${against}`);
failCommand();
}
if (against && !existsSync(against))
fail(`${c.error("✗")} --against video not found: ${against}`);
const label = atTimestamps
? `${atTimestamps.length} frames at [${atTimestamps.map(formatSnapshotTimestamp).join(", ")}]`
: `${frames} frames`;
const label = reviewMode
? "review frames (beats, seams, phone)"
: atTimestamps
? `${atTimestamps.length} frames at [${atTimestamps.map(formatSnapshotTimestamp).join(", ")}]`
: `${frames} frames`;
const angleLabel =
camera && (camera.yaw !== 0 || camera.pitch !== 0)
? ` ${c.dim(`(angle yaw ${camera.yaw}° pitch ${camera.pitch}°)`)}`
: "";
console.log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}${angleLabel}`);
log(`${c.accent("◆")} Capturing ${label} from ${c.accent(project.name)}${angleLabel}`);
let managedSnapshotDir: string | undefined;
try {
@@ -1160,7 +1332,7 @@ export default defineCommand({
if (!args.output) managedSnapshotDir = snapshotDir;
if (args.output) retainSnapshotOutput(project.dir, snapshotDir);
await withProjectLock(project.dir, "snapshot", async () => {
const paths = await captureSnapshots(project.dir, {
const captured = await captureSnapshots(project.dir, {
frames,
range,
composition,
@@ -1175,7 +1347,10 @@ export default defineCommand({
browserGpuMode: resolveLocalBrowserGpuMode(args["browser-gpu"] as boolean | undefined),
against,
keep: args.keep === true,
review: reviewMode,
log,
});
const paths = captured.paths;
reportArtifacts(
"snapshot",
"frame",
@@ -1183,35 +1358,46 @@ export default defineCommand({
);
if (paths.length === 0) {
console.log(
`\n${c.error("✗")} Could not determine composition duration — no frames captured`,
);
failCommand();
fail(`\n${c.error("✗")} Could not determine composition duration — no frames captured`);
}
console.log(`\n${c.success("◇")} ${paths.length} snapshots saved to ${snapshotDir}`);
log(`\n${c.success("◇")} ${paths.length} snapshots saved to ${snapshotDir}`);
for (const p of paths) {
console.log(` ${p}`);
log(` ${p}`);
}
if (against) {
console.log(
log(
` ${c.dim("ref-*.png + pair-*.jpg")} beside each frame (reference frame, render | reference sheet)`,
);
}
// Generate contact sheet for quick AI review; a --keep folder mixes runs, so it gets none.
let contactSheets: string[] = [];
let reviewSheets: ReviewSheets | undefined;
try {
const { createSnapshotContactSheet } = await import("../capture/contactSheet.js");
const sheets =
args.keep === true
? []
: await createSnapshotContactSheet(
snapshotDir,
join(snapshotDir, "contact-sheet.jpg"),
);
reportArtifacts("snapshot", "contact-sheet", sheets);
for (const sheet of sheets) {
console.log(` ${resolve(sheet)} (grid view for AI review)`);
if (captured.review) {
const written = new Map(
captured.review.frames.map((frame) => [frame.file, frame.path]),
);
reviewSheets = await writeReviewSheets(
snapshotDir,
captured.review.frames,
(frame) => written.get(frame.file) ?? null,
);
contactSheets = [...reviewSheets.beats, ...reviewSheets.seams, ...reviewSheets.phone];
printReview(captured.review, reviewSheets, log);
} else if (args.keep !== true) {
contactSheets = await createSnapshotContactSheet(
snapshotDir,
join(snapshotDir, "contact-sheet.jpg"),
);
}
reportArtifacts("snapshot", "contact-sheet", contactSheets);
if (!captured.review) {
for (const sheet of contactSheets) {
log(` ${resolve(sheet)} (grid view for AI review)`);
}
}
} catch (err) {
console.warn(
@@ -1219,6 +1405,7 @@ export default defineCommand({
);
}
let describedPath: string | undefined;
// Gemini vision descriptions. Runs by default — see describeArg
// resolution above. `null` means the user explicitly opted out with
// `--describe false`; missing GEMINI_API_KEY logs a skip and continues.
@@ -1227,9 +1414,9 @@ export default defineCommand({
const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
if (!geminiKey) {
if (args.describe !== undefined)
console.log(` ${c.dim("--describe: GEMINI_API_KEY not set, skipping")}`);
log(` ${c.dim("--describe: GEMINI_API_KEY not set, skipping")}`);
} else if (paths.length > 0) {
console.log(` ${c.dim("Describing frames with Gemini vision...")}`);
log(` ${c.dim("Describing frames with Gemini vision...")}`);
const { GoogleGenAI } = await loadOptionalPackage("@google/genai", "--describe");
const ai = new GoogleGenAI({ apiKey: geminiKey });
const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview";
@@ -1310,17 +1497,29 @@ export default defineCommand({
const descPath = join(snapshotDir, "descriptions.md");
writeFileSync(descPath, descriptions.join("\n"));
console.log(` ${c.dim("descriptions.md")} (Gemini frame analysis)`);
describedPath = descPath;
log(` ${c.dim("descriptions.md")} (Gemini frame analysis)`);
}
} catch (descErr) {
const msg = normalizeErrorMessage(descErr);
console.log(` ${c.dim(`--describe failed: ${msg}`)}`);
log(` ${c.dim(`--describe failed: ${msg}`)}`);
}
}
if (asJson) {
const envelope = snapshotEnvelope(project.dir, snapshotDir, captured, {
contactSheets,
reviewSheets,
describedPath,
});
console.log(JSON.stringify(withMeta(envelope), null, 2));
}
});
} catch (err) {
// Already reported where it happened (fail() above).
if (err instanceof CliRuntimeError && err.result.presented) throw err;
const msg = normalizeErrorMessage(err);
console.error(`\n${c.error("✗")} Snapshot failed: ${msg}`);
if (asJson) console.log(JSON.stringify(withMeta({ ok: false, error: msg }), null, 2));
else console.error(`\n${c.error("✗")} Snapshot failed: ${msg}`);
failCommand();
} finally {
if (managedSnapshotDir)
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import {
REVIEW_SEAM_OFFSETS,
planReview,
resolveReviewTimeline,
reviewFrames,
seamTimes,
} from "./snapshotReview.js";
const FILM = `<html><body><div data-composition-id="main" data-duration="20">
<div id="intro" data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="0" data-duration="6"></div>
<div id="stat" data-composition-id="stat" data-composition-src="compositions/stat.html" data-start="6" data-duration="7">
<div class="scene-body">
<div id="number" class="clip" data-start="1.2" data-duration="5.8" data-beat="number">42%</div>
<div id="caption" data-beat="caption">untimed</div>
</div>
</div>
<div id="outro" data-composition-id="outro" data-start="stat" data-duration="7"></div>
<div id="captions" data-track-kind="captions" data-start="0" data-duration="20"></div>
<audio id="music" src="music.mp3" data-start="0" data-duration="20"></audio>
</div></body></html>`;
describe("resolveReviewTimeline", () => {
it("reads the root's timed scene hosts, skipping captions and audio tracks", () => {
const timeline = resolveReviewTimeline(FILM, {}, 20);
expect(timeline.window).toEqual([0, 20]);
expect(timeline.scenes).toEqual([
{ id: "intro", start: 0, end: 6 },
{ id: "stat", start: 6, end: 13 },
{ id: "outro", start: 13, end: 20 },
]);
});
it("times a nested data-beat on the root timeline and notes an untimed one", () => {
const timeline = resolveReviewTimeline(FILM, {}, 20);
expect(timeline.beats).toEqual([{ time: 7.5, label: "beat:number" }]);
expect(timeline.notes).toEqual([expect.stringContaining('data-beat="caption"')]);
});
it("scopes to one composition with --composition", () => {
const timeline = resolveReviewTimeline(FILM, {}, 20, "stat");
expect(timeline.window).toEqual([6, 13]);
expect(timeline.scenes).toEqual([{ id: "number", start: 7.2, end: 13 }]);
});
it("times a host without data-duration from its registered timeline", () => {
const html = `<div data-composition-id="main" data-duration="10">
<div data-composition-id="a" data-start="0"></div>
<div data-composition-id="b" data-start="a"></div>
</div>`;
expect(resolveReviewTimeline(html, { a: 4, b: 6 }, 10).scenes).toEqual([
{ id: "a", start: 0, end: 4 },
{ id: "b", start: 4, end: 10 },
]);
});
});
describe("planReview", () => {
it("frames each scene 0.3 s in and at its middle, and strips every cut at ±0.1 and ±0.2 s", () => {
const plan = planReview(resolveReviewTimeline(FILM, {}, 20), 20 - 1 / 30);
expect(plan.beats).toEqual([
{ time: 0.3, label: "intro" },
{ time: 3, label: "intro mid" },
{ time: 6.3, label: "stat" },
{ time: 7.5, label: "beat:number" },
{ time: 9.5, label: "stat mid" },
{ time: 13.3, label: "outro" },
{ time: 16.5, label: "outro mid" },
]);
expect(plan.seams).toEqual([
{ time: 6, from: "intro", to: "stat", times: [5.8, 5.9, 6, 6.1, 6.2] },
{ time: 13, from: "stat", to: "outro", times: [12.8, 12.9, 13, 13.1, 13.2] },
]);
});
it("merges a data-beat that lands on a scene's beat into one frame", () => {
const plan = planReview(
{
window: [0, 10],
scenes: [
{ id: "a", start: 0, end: 5 },
{ id: "b", start: 5, end: 10 },
],
beats: [{ time: 5.31, label: "beat:logo" }],
notes: [],
},
10,
);
expect(plan.beats.map((beat) => beat.label)).toEqual(["a", "a mid", "b + beat:logo", "b mid"]);
});
it("uses a short scene's middle and keeps every frame inside the film", () => {
const plan = planReview(
{
window: [0, 4],
scenes: [
{ id: "flash", start: 0, end: 0.4 },
{ id: "rest", start: 0.4, end: 4 },
],
beats: [],
notes: [],
},
4 - 1 / 30,
);
expect(plan.beats[0]).toEqual({ time: 0.2, label: "flash" });
expect(plan.seams[0]?.times).toEqual([0.2, 0.3, 0.4, 0.5, 0.6]);
});
it("spreads beats evenly when there are fewer than two scenes", () => {
const plan = planReview({ window: [0, 10], scenes: [], beats: [], notes: [] }, 10);
expect(plan.beats.map((beat) => beat.time)).toEqual([1, 3, 5, 7, 9]);
expect(plan.notes[0]).toContain("Fewer than two scene starts");
});
it("spreads beats evenly when every scene starts at the same time", () => {
const scenes = [
{ id: "basemap", start: 0, end: 10 },
{ id: "pin", start: 0, end: 10 },
];
const plan = planReview({ window: [0, 10], scenes, beats: [], notes: [] }, 10);
expect(plan.beats.map((beat) => beat.time)).toEqual([1, 3, 5, 7, 9]);
});
});
describe("seamTimes", () => {
it("adds an end no scene starts at, for a gap or an overlapping transition", () => {
const scenes = [
{ id: "a", start: 0, end: 4 },
{ id: "b", start: 3.5, end: 8 },
{ id: "c", start: 9, end: 12 },
];
expect(seamTimes(scenes, [0, 12])).toEqual([3.5, 4, 8, 9]);
});
});
describe("reviewFrames", () => {
it("names beat and seam stills in capture order", () => {
const plan = planReview(resolveReviewTimeline(FILM, {}, 20), 20 - 1 / 30);
const frames = reviewFrames(plan);
expect(frames).toHaveLength(plan.beats.length + plan.seams.length * REVIEW_SEAM_OFFSETS.length);
expect(frames[0]).toMatchObject({ role: "beat", file: "beat-01-at-0.3s.png", label: "intro" });
expect(frames.find((frame) => frame.role === "seam" && frame.time === 6)).toMatchObject({
file: "seam-01-3-at-6s.png",
label: "cut intro -> stat",
});
});
});
+320
View File
@@ -0,0 +1,320 @@
import { parseHTML } from "linkedom";
import { topLevelElements, trackKindOf } from "@hyperframes/parsers";
import { toDomNode } from "../timeline/domStructure.js";
import {
resolveSnapshotCompositionRange,
snapshotStartResolver,
type SnapshotRange,
} from "./snapshotSampling.js";
/** A beat's frame lands this long after its start, once the entrance has settled. */
const REVIEW_SETTLE_SEC = 0.3;
const REVIEW_MID_GAP_SEC = 0.5;
/** Seam strip offsets around each cut. */
export const REVIEW_SEAM_OFFSETS = [-0.2, -0.1, 0, 0.1, 0.2] as const;
/** Phone sheet tile width: the frame as wide as a phone held upright. */
export const REVIEW_PHONE_WIDTH = 360;
/** Starts and ends closer than this are one boundary. */
const SAME_TIME_SEC = 0.05;
/** Without two scene starts there is nothing to cut between, so the beats are spread evenly. */
const EVEN_BEATS = 5;
export interface ReviewScene {
id: string;
start: number;
end: number;
}
export interface ReviewBeat {
time: number;
/** Scene id, `beat:<data-beat>` for an authored beat, or `t=<time>` when sampled evenly. */
label: string;
}
export interface ReviewSeam {
time: number;
from: string | null;
to: string | null;
times: number[];
}
export interface ReviewPlan {
window: SnapshotRange;
scenes: ReviewScene[];
beats: ReviewBeat[];
seams: ReviewSeam[];
notes: string[];
}
function roundMs(value: number): number {
return Math.round(value * 1000) / 1000;
}
function sceneId(el: Element, index: number): string {
return (
el.id ||
el.getAttribute("data-composition-id") ||
el.getAttribute("data-layout-name") ||
`${el.tagName.toLowerCase()}-${index + 1}`
);
}
/**
* The review's scenes and authored beats, read like `timeline` reads rows: the composition's
* top-level timed elements and sub-composition hosts (captions and audio tracks excluded), timed by
* the runtime's own start resolver over the live page's markup. `durations` are the registered
* GSAP timeline lengths, which time a host that declares no data-duration.
*/
export function resolveReviewTimeline(
html: string,
durations: Record<string, number>,
duration: number,
compositionId?: string,
): { window: SnapshotRange; scenes: ReviewScene[]; beats: ReviewBeat[]; notes: string[] } {
const bounds: SnapshotRange = compositionId
? resolveSnapshotCompositionRange(html, durations, compositionId, duration)
: [0, duration];
const { document } = parseHTML(html);
const target = compositionId
? Array.from(document.querySelectorAll("[data-composition-id]")).find(
(el) => el.getAttribute("data-composition-id") === compositionId,
)
: document.querySelector("[data-composition-id]");
if (!target) {
return { window: bounds, scenes: [], beats: [], notes: ["No composition root found."] };
}
const resolver = snapshotStartResolver(document as unknown as Document, durations);
const span = (el: Element): [number, number] | null => {
// The fallback reaches every untimed ancestor, the root included, so it must be the root's 0.
const start = resolver.resolveStartForElement(el, 0);
if (!Number.isFinite(start)) return null;
const length = resolver.resolveDurationForElement(el);
const from = Math.max(bounds[0], start);
const to = Math.min(bounds[1], length != null ? start + length : bounds[1]);
return to > from ? [roundMs(from), roundMs(to)] : null;
};
const scenes: ReviewScene[] = [];
const rows = topLevelElements(toDomNode(target as unknown as Element)).filter((node) => {
const kind = trackKindOf(node).kind;
return kind === "video" || kind === "graphics";
});
rows.forEach((node, index) => {
const interval = span(node.el);
if (interval)
scenes.push({ id: sceneId(node.el, index), start: interval[0], end: interval[1] });
});
const beats: ReviewBeat[] = [];
const notes: string[] = [];
for (const el of Array.from((target as unknown as Element).querySelectorAll("[data-beat]"))) {
const name = el.getAttribute("data-beat")?.trim() || sceneId(el, beats.length);
const timed = el.hasAttribute("data-start") || el.hasAttribute("data-composition-id");
const interval = timed ? span(el) : null;
if (!interval) {
notes.push(`data-beat="${name}" was skipped: give it a data-start so the beat has a time.`);
continue;
}
beats.push({ time: settledTime(interval[0], interval[1]), label: `beat:${name}` });
}
scenes.sort((a, b) => a.start - b.start || a.end - b.end);
return { window: bounds, scenes, beats, notes };
}
/** Just after a scene starts, and its middle once that is far enough on to show what the scene builds. */
function sceneBeats(scene: ReviewScene): ReviewBeat[] {
const settled = settledTime(scene.start, scene.end);
const middle = roundMs((scene.start + scene.end) / 2);
const beats = [{ time: settled, label: scene.id }];
return middle - settled >= REVIEW_MID_GAP_SEC
? [...beats, { time: middle, label: `${scene.id} mid` }]
: beats;
}
/** start + 0.3 s, or the middle of a span too short for that. */
function settledTime(start: number, end: number): number {
return roundMs(Math.min(start + REVIEW_SETTLE_SEC, (start + end) / 2));
}
/** Beats, seams and their frame times. `lastFrame` is the latest seek that still shows the film. */
export function planReview(
timeline: { window: SnapshotRange; scenes: ReviewScene[]; beats: ReviewBeat[]; notes: string[] },
lastFrame: number,
): ReviewPlan {
const { window: bounds, scenes } = timeline;
const notes = [...timeline.notes];
const clamp = (time: number) => roundMs(Math.min(Math.max(time, bounds[0]), lastFrame));
const distinctStarts = mergeBeats(
scenes.map((scene) => ({ time: scene.start, label: scene.id })),
);
const beats = mergeBeats([
...(distinctStarts.length >= 2 ? scenes.flatMap(sceneBeats) : evenBeats(bounds, notes)),
...timeline.beats,
]).map((beat) => ({ ...beat, time: clamp(beat.time) }));
const seams = seamTimes(scenes, bounds).map((time) => ({
time,
...seamSides(scenes, time),
times: REVIEW_SEAM_OFFSETS.map((offset) => clamp(time + offset)),
}));
return { window: bounds, scenes, beats, seams, notes };
}
function evenBeats(bounds: SnapshotRange, notes: string[]): ReviewBeat[] {
notes.push(
`Fewer than two scene starts; ${EVEN_BEATS} beats are spread evenly instead. Time scenes as sub-composition hosts or clips to review each one.`,
);
const [start, end] = bounds;
return Array.from({ length: EVEN_BEATS }, (_, index) => {
const time = roundMs(start + ((index + 0.5) / EVEN_BEATS) * (end - start));
return { time, label: `t=${time}` };
});
}
function mergeBeats(beats: ReviewBeat[]): ReviewBeat[] {
const merged: ReviewBeat[] = [];
for (const beat of [...beats].sort((a, b) => a.time - b.time)) {
const same = merged.find((entry) => Math.abs(entry.time - beat.time) < SAME_TIME_SEC);
if (same) same.label = `${same.label} + ${beat.label}`;
else merged.push({ ...beat });
}
return merged;
}
/** Every scene start after the window opens, and every scene end no other scene starts at. */
export function seamTimes(scenes: ReviewScene[], bounds: SnapshotRange): number[] {
const inside = (time: number) =>
time > bounds[0] + SAME_TIME_SEC && time < bounds[1] - SAME_TIME_SEC;
const starts = scenes.map((scene) => scene.start).filter(inside);
const ends = scenes
.map((scene) => scene.end)
.filter(inside)
.filter((end) => !starts.some((start) => Math.abs(start - end) < SAME_TIME_SEC));
const seams: number[] = [];
for (const time of [...starts, ...ends].sort((a, b) => a - b)) {
if (!seams.some((seam) => Math.abs(seam - time) < SAME_TIME_SEC)) seams.push(time);
}
return seams;
}
function seamSides(
scenes: ReviewScene[],
time: number,
): { from: string | null; to: string | null } {
const before = scenes
.filter((scene) => scene.start < time - SAME_TIME_SEC && scene.end >= time - SAME_TIME_SEC)
.sort((a, b) => b.start - a.start)[0];
const after = scenes
.filter((scene) => Math.abs(scene.start - time) < SAME_TIME_SEC)
.sort((a, b) => a.end - b.end)[0];
const next = after ?? scenes.find((scene) => scene.start > time);
return { from: before?.id ?? null, to: next?.id ?? null };
}
export interface ReviewFrame {
role: "beat" | "seam";
/** 1-based beat or seam number. */
index: number;
time: number;
label: string;
file: string;
}
function pad(index: number): string {
return String(index).padStart(2, "0");
}
function stamp(time: number): string {
return `${Number(time.toFixed(3))}s`;
}
const SEAM_TILE_LABELS = ["cut -0.2", "-0.1", "cut", "+0.1", "+0.2"] as const;
/** Every still a review captures, in capture order, with its file name. */
export function reviewFrames(plan: ReviewPlan): ReviewFrame[] {
const beats = plan.beats.map((beat, index) => ({
role: "beat" as const,
index: index + 1,
time: beat.time,
label: beat.label,
file: `beat-${pad(index + 1)}-at-${stamp(beat.time)}.png`,
}));
const seams = plan.seams.flatMap((seam, index) =>
seam.times.map((time, slot) => ({
role: "seam" as const,
index: index + 1,
time,
label:
slot === 2
? `cut ${seam.from ?? "start"} -> ${seam.to ?? "end"}`
: (SEAM_TILE_LABELS[slot] ?? ""),
file: `seam-${pad(index + 1)}-${slot + 1}-at-${stamp(time)}.png`,
})),
);
return [...beats, ...seams];
}
export interface ReviewSheets {
beats: string[];
seams: string[];
phone: string[];
}
const BEAT_SHEET_PAGE = 16;
/** The beat sheet, one strip per seam, and the beat frames again at phone width. */
export async function writeReviewSheets(
dir: string,
frames: ReviewFrame[],
written: (frame: ReviewFrame) => string | null,
): Promise<ReviewSheets> {
const { createContactSheet } = await import("../capture/contactSheet.js");
const { join } = await import("node:path");
const captured = frames.flatMap((frame) => {
const path = written(frame);
return path ? [{ frame, path }] : [];
});
const beats = captured.filter((entry) => entry.frame.role === "beat");
const beatLabel = ({ frame }: (typeof captured)[number]) => `${stamp(frame.time)} ${frame.label}`;
const pages = async (name: string, cellWidth: number, colsFor: (count: number) => number) => {
const sheets: string[] = [];
for (let start = 0; start < beats.length; start += BEAT_SHEET_PAGE) {
const page = beats.slice(start, start + BEAT_SHEET_PAGE);
const suffix = beats.length > BEAT_SHEET_PAGE ? `-${start / BEAT_SHEET_PAGE + 1}` : "";
const sheet = await createContactSheet(
page.map((entry) => entry.path),
join(dir, `${name}${suffix}.jpg`),
{
cols: colsFor(page.length),
maxImages: page.length,
cellWidth,
labelMode: "custom",
labels: page.map(beatLabel),
},
);
if (sheet) sheets.push(sheet);
}
return sheets;
};
const seams: string[] = [];
const seamNumbers = [
...new Set(captured.filter((e) => e.frame.role === "seam").map((e) => e.frame.index)),
];
for (const index of seamNumbers) {
const strip = captured.filter((e) => e.frame.role === "seam" && e.frame.index === index);
const sheet = await createContactSheet(
strip.map((entry) => entry.path),
join(dir, `review-seam-${pad(index)}.jpg`),
{
cols: strip.length,
maxImages: strip.length,
cellWidth: 384,
labelMode: "custom",
labels: strip.map(({ frame }) => `${stamp(frame.time)} ${frame.label}`),
},
);
if (sheet) seams.push(sheet);
}
return {
beats: await pages("review-beats", 480, (count) => (count <= 3 ? count : count <= 9 ? 3 : 4)),
seams,
phone: await pages("review-phone", REVIEW_PHONE_WIDTH, (count) => Math.min(4, count)),
};
}
+10 -8
View File
@@ -37,6 +37,15 @@ export function sampleSnapshotRange(
exclusiveEnd || end === duration ? end - Math.min(0.001, (end - start) / (frames * 2)) : end;
return Array.from({ length: frames }, (_, i) => start + ((last - start) * i) / (frames - 1));
}
export function snapshotStartResolver(document: Document, durations: Record<string, number>) {
return createRuntimeStartTimeResolver({
documentRef: document,
timelineRegistry: Object.fromEntries(
Object.entries(durations).map(([key, value]) => [key, { duration: () => value }]),
),
includeAuthoredTimingAttrs: true,
});
}
export function resolveSnapshotCompositionRange(
html: string,
durations: Record<string, number>,
@@ -51,14 +60,7 @@ export function resolveSnapshotCompositionRange(
throw new Error(
`Composition ID ${JSON.stringify(id)} matched ${matches.length} elements; expected exactly one`,
);
const registry = Object.fromEntries(
Object.entries(durations).map(([key, value]) => [key, { duration: () => value }]),
);
const resolver = createRuntimeStartTimeResolver({
documentRef: document as unknown as Document,
timelineRegistry: registry,
includeAuthoredTimingAttrs: true,
});
const resolver = snapshotStartResolver(document as unknown as Document, durations);
let start = 0,
end = duration;
for (let el: Element | null = matches[0] as unknown as Element; el; el = el.parentElement) {
+1 -1
View File
@@ -11,7 +11,7 @@ Core attributes for controlling element timing and behavior.
## Media
- `data-media-start="2"` — Media playback offset / trim point (seconds)
- `data-volume="0.8"` — Audio/video gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB). For ducking and shaped envelopes use the `data-automation` volume lane (`hyperframes-core` skill, `creator-editing-recipes.md`)
- `data-volume="0.8"` — Audio/video gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB). For ducking and shaped envelopes use the `data-automation` volume lane (`/media-use` → `references/mixing.md`)
- `data-fade-in="0.5"` / `data-fade-out="1"` — Clip-edge fades (seconds): linear gain ramps anchored to the clip's start and end, multiplied on top of `data-volume` and any volume lane
- `data-has-audio="true"` — Keeps the file's sound on this clip (the default for footage with sound). Silent footage uses `muted` instead.
@@ -15,7 +15,7 @@ import { globalMediaDir } from "./media-home.mjs";
* project files can't see each other, so the cross-project count has to live
* user-side.
*
* Consumption contract (brief-contract § 2, Remembered defaults): a remembered
* Consumption contract (media-use references/memory.md): a remembered
* value becomes the recommended option with a receipt naming its source — it
* never skips a question, and explicit request content always wins.
*/
@@ -38,7 +38,7 @@ const NON_ASCII_SAFE_DIR_COPY = { recursive: true, force: true, filter: () => tr
const FRONTMATTER_CONTENT_KEYS = new Set(["message", "audience", "mode"]);
/** BRIEF.md frontmatter keys that describe this run, not the reusable type —
* a recipe never locks the run's shape, so the intent layer always re-asks. */
* a recipe never locks the run's shape, so these are always asked again. */
const BRIEF_CONTENT_KEYS = new Set(["flow", "storyboard", "message", "audience"]);
/** Per-frame metadata that is content, not structure. */
@@ -118,6 +118,7 @@ describe("trackCheckReport", () => {
motionWarnings: 8,
contrastErrors: 9,
contrastWarnings: 10,
craftWarnings: 16,
launchSettleMs: 11,
seekLoopMs: 12,
contrastMs: 13,
@@ -144,6 +145,7 @@ describe("trackCheckReport", () => {
motion_warnings: 8,
contrast_errors: 9,
contrast_warnings: 10,
craft_warnings: 16,
launch_settle_ms: 11,
seek_loop_ms: 12,
contrast_ms: 13,
@@ -172,6 +174,7 @@ describe("trackCheckReport", () => {
motionWarnings: 0,
contrastErrors: 0,
contrastWarnings: 0,
craftWarnings: 0,
launchSettleMs: 0,
seekLoopMs: 0,
contrastMs: 0,
+4 -2
View File
@@ -336,7 +336,7 @@ export function trackRenderComplete(
durationMs: number;
fps: number;
quality: string;
/** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
/** Authoring skill that drove this render (e.g. "hyperframes"). */
authoringSkill?: string;
/** Which step resolved authoringSkill: an explicit --skill flag, or the project's own config. */
authoringSkillSource?: string;
@@ -653,7 +653,7 @@ export function trackRenderError(
props: {
fps: number;
quality: string;
/** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
/** Authoring skill that drove this render (e.g. "hyperframes"). */
authoringSkill?: string;
/** Which step resolved authoringSkill: an explicit --skill flag, or the project's own config. */
authoringSkillSource?: string;
@@ -1107,6 +1107,7 @@ export function trackCheckReport(props: {
motionWarnings: number;
contrastErrors: number;
contrastWarnings: number;
craftWarnings: number;
launchSettleMs: number;
seekLoopMs: number;
contrastMs: number;
@@ -1132,6 +1133,7 @@ export function trackCheckReport(props: {
motion_warnings: props.motionWarnings,
contrast_errors: props.contrastErrors,
contrast_warnings: props.contrastWarnings,
craft_warnings: props.craftWarnings,
launch_settle_ms: props.launchSettleMs,
seek_loop_ms: props.seekLoopMs,
contrast_ms: props.contrastMs,
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Authoring-skill slug helpers, shared by the `events` and `render` commands.
*
* A skill slug names the authoring workflow that drove a telemetry event
* (e.g. "product-launch-video"). Values are slug-gated so a caller can't push
* A skill slug names the authoring skill that drove a telemetry event
* (e.g. "hyperframes"). Values are slug-gated so a caller can't push
* high-cardinality or PII strings (paths, shell output, free text) into the
* anonymous event stream.
*/
+14 -19
View File
@@ -1,36 +1,29 @@
# HyperFrames Composition Project
## Skills — USE THESE FIRST
## Skills — read these first
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
**Invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that generic web docs don't cover, so compositions written without them break.
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
**Doing anything with HyperFrames?** Start at `/hyperframes`. Make any video by asking for it; the `/hyperframes` skill carries the contract and the references (each kind of film, story, motion, look, sound, review, captions, overlays). Read it first, especially when there's no project context to orient you.
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
- `/music-to-video` — a **music track** (audio file, video to pull audio from, or one generated from a mood brief) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-audio`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
- `/hyperframes` — read first for any request to make, edit, animate or render a video, motion graphic, captions or overlays. Holds the render contract and the work loop.
- `/hyperframes-cli` — the CLI by job: scaffold, check, look at frames, edit the timeline, preview, render, publish, project history.
- `/hyperframes-registry` — ready-made blocks and components installed as editable source. Search it before hand-building any named visual.
- `/media-use` — get or make any media (music, sound effects, images, voiceover, avatar and generated video, transcripts, cutouts, colour grades) and mix placed audio.
- `/figma` — bring a Figma design, frame, logo or brand into a video.
- `/slideshow` — a presentation or pitch deck. Output is a navigable deck, not a rendered MP4.
- `/remotion-to-hyperframes` — bring existing video code or footage into HyperFrames (Remotion first; also Motion Canvas, Lottie, Three.js, canvas, or renders from other tools) and verify it frame by frame against the original.
**Changing how real footage or images look or reveal?** Load `/media-use` and read its `references/media-treatments.md` before editing, even when the request only says dark, flat, boring, retro, private, or “make the reveal cooler.” It governs how footage is treated, never whether media may be used. Use canonical media treatments and seek-safe motion; do not improvise equivalent CSS/SVG filters or overlays.
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `hyperframes-cli/references/init-and-scaffold.md` (Tailwind).
> **Using a HyperFrames plugin?** Load skills from that installed bundle and follow
> its `hyperframes/references/plugin-installation.md` execution rules. Update via
> the plugin manager, not the standalone commands below.
>
> **Standalone skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> the specific skill you need, or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
> newly installed skills load.
@@ -92,6 +85,8 @@ npm run check
Fix all errors before presenting the result. Warnings should be reviewed before rendering.
Then look at what you changed: `npx hyperframes snapshot --at <seconds>` (or `--range START,END`, `--composition <id>`) at the moments you edited, and read the frames before you reply. A passing check does not show how it looks.
## Key Rules
1. Every timed element needs `data-start` and a duration. `data-start` is what marks it as timed; `data-track-index` is an optional Studio display lane the render never reads
+14 -19
View File
@@ -1,36 +1,29 @@
# HyperFrames Composition Project
## Skills — USE THESE FIRST
## Skills — read these first
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
**Invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that generic web docs don't cover, so compositions written without them break.
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
**Doing anything with HyperFrames?** Start at `/hyperframes`. Make any video by asking for it; the `/hyperframes` skill carries the contract and the references (each kind of film, story, motion, look, sound, review, captions, overlays). Read it first, especially when there's no project context to orient you.
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
- `/music-to-video` — a **music track** (audio file, video to pull audio from, or one generated from a mood brief) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-audio`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
- `/hyperframes` — read first for any request to make, edit, animate or render a video, motion graphic, captions or overlays. Holds the render contract and the work loop.
- `/hyperframes-cli` — the CLI by job: scaffold, check, look at frames, edit the timeline, preview, render, publish, project history.
- `/hyperframes-registry` — ready-made blocks and components installed as editable source. Search it before hand-building any named visual.
- `/media-use` — get or make any media (music, sound effects, images, voiceover, avatar and generated video, transcripts, cutouts, colour grades) and mix placed audio.
- `/figma` — bring a Figma design, frame, logo or brand into a video.
- `/slideshow` — a presentation or pitch deck. Output is a navigable deck, not a rendered MP4.
- `/remotion-to-hyperframes` — bring existing video code or footage into HyperFrames (Remotion first; also Motion Canvas, Lottie, Three.js, canvas, or renders from other tools) and verify it frame by frame against the original.
**Changing how real footage or images look or reveal?** Load `/media-use` and read its `references/media-treatments.md` before editing, even when the request only says dark, flat, boring, retro, private, or “make the reveal cooler.” It governs how footage is treated, never whether media may be used. Use canonical media treatments and seek-safe motion; do not improvise equivalent CSS/SVG filters or overlays.
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `hyperframes-cli/references/init-and-scaffold.md` (Tailwind).
> **Using a HyperFrames plugin?** Load skills from that installed bundle and follow
> its `hyperframes/references/plugin-installation.md` execution rules. Update via
> the plugin manager, not the standalone commands below.
>
> **Standalone skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> the specific skill you need, or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
> newly installed skills load.
@@ -92,6 +85,8 @@ npm run check
Fix all errors before presenting the result. Warnings should be reviewed before rendering.
Then look at what you changed: `npx hyperframes snapshot --at <seconds>` (or `--range START,END`, `--composition <id>`) at the moments you edited, and read the frames before you reply. A passing check does not show how it looks.
## Key Rules
1. Every timed element needs `data-start` and a duration. `data-start` is what marks it as timed; `data-track-index` is an optional Studio display lane the render never reads
+10 -4
View File
@@ -5,6 +5,12 @@
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
/* Placeholders: replace all three with the film's own palette and display face. */
:root {
--ground: #6b6b6b;
--ink: #ffffff;
--font-display: sans-serif;
}
* {
margin: 0;
padding: 0;
@@ -16,7 +22,7 @@
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0a0a0a;
background: var(--ground);
}
#root {
width: 100%;
@@ -24,10 +30,10 @@
display: flex;
align-items: center;
justify-content: center;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
font-family: var(--font-display);
}
#title {
color: #f4f4f5;
color: var(--ink);
font-size: 72px;
font-weight: 600;
letter-spacing: -0.03em;
@@ -47,7 +53,7 @@
</div>
<script>
const tl = gsap.timeline({ paused: true });
// Example: tl.fromTo("#title", { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.6 }, 0);
// Example: tl.fromTo("#title", { opacity: 0, filter: "blur(8px)" }, { opacity: 1, filter: "blur(0px)", duration: 0.18, ease: "power4.out" }, 0);
window.__timelines["main"] = tl;
tl.seek(0);
</script>
+3 -13
View File
@@ -23,9 +23,9 @@ import {
isSubCompositionHost,
topLevelElements,
trackKindOf,
type StructureNode,
type TrackKind,
} from "@hyperframes/parsers";
import { toDomNode, type DomNode } from "./domStructure.js";
import {
compositionOriginSeconds,
cutToHostSlots,
@@ -105,18 +105,8 @@ export interface ProjectTimeline {
tracks: TimelineTrack[];
}
interface DomNode extends StructureNode<DomNode> {
el: Element;
}
const TRACK_ORDER: readonly TrackKind[] = ["video", "graphics", "captions", "audio"];
function toNode(el: Element): DomNode {
const attrs: Record<string, string | undefined> = {};
for (const { name, value } of Array.from(el.attributes)) attrs[name] = value;
return { tag: el.tagName, attrs, children: Array.from(el.children).map(toNode), el };
}
/** An unreadable chain drops only the fx lanes; the clip's own `volume` lane still shows. */
function safeChain(raw: string): ReturnType<typeof parseAudioFxChain> | undefined {
try {
@@ -388,7 +378,7 @@ async function readSubComposition(
fps: parent.fps,
};
const rows = await Promise.all(
topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 1)),
topLevelElements(toDomNode(root)).map((node) => describeRow(scope, node, 1)),
);
return rows.flatMap((row) => cutRow(row, slots)).sort(byStart);
}
@@ -517,7 +507,7 @@ export async function describeProject(
fps: projectFps(source),
};
const rows = (
await Promise.all(topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 0)))
await Promise.all(topLevelElements(toDomNode(root)).map((node) => describeRow(scope, node, 0)))
).sort(byStart);
const declared = parseNumeric(root.getAttribute("data-duration"));
return {
+12
View File
@@ -0,0 +1,12 @@
import type { StructureNode } from "@hyperframes/parsers";
/** A DOM element as the parsers' structural node, keeping the element for timing lookups. */
export interface DomNode extends StructureNode<DomNode> {
el: Element;
}
export function toDomNode(el: Element): DomNode {
const attrs: Record<string, string | undefined> = {};
for (const { name, value } of Array.from(el.attributes)) attrs[name] = value;
return { tag: el.tagName, attrs, children: Array.from(el.children).map(toDomNode), el };
}
+58
View File
@@ -57,6 +57,9 @@ import type {
CheckSeverity,
ContrastAuditEntry,
ContrastCapture,
CraftFilm,
CraftFrame,
CraftTextPart,
GeometryCandidateRequest,
LayoutOptions,
MotionSpecResolution,
@@ -788,9 +791,64 @@ export function createPageDriver(
collectMotionFrame(page, time, selectors, scopes),
anchorMotionIssues: (issues) => anchorLayoutIssues(page, issues),
collectContrast: (time, annotations) => collectContrast(page, time, annotations),
collectCraftFrame: (time) => collectCraftFrame(page, time),
collectCraftFilm: () => collectCraftFilm(page),
};
}
async function collectCraftFrame(page: Page, time: number): Promise<CraftFrame> {
const raw = await page.evaluate(() => {
const sample = Reflect.get(window, "__hyperframesCraftSample");
return typeof sample === "function" ? Reflect.apply(sample, window, []) : null;
});
const record = isRecord(raw) ? raw : {};
const parts = Reflect.get(record, "parts");
return {
time,
parts: Array.isArray(parts) ? parts.flatMap(parseCraftTextPart) : [],
held: booleanValue(record, "held") ?? false,
painted: booleanValue(record, "painted") ?? true,
signature: stringValue(record, "signature") ?? "",
};
}
async function collectCraftFilm(page: Page): Promise<CraftFilm> {
const raw = await page.evaluate(() => {
const film = Reflect.get(window, "__hyperframesCraftFilm");
return typeof film === "function" ? Reflect.apply(film, window, []) : null;
});
return { soundSources: (isRecord(raw) ? numberValue(raw, "soundSources") : null) ?? 0 };
}
const CRAFT_PART_NUMBERS = ["key", "block", "opacity", "fontPx"] as const;
const CRAFT_PART_STRINGS = ["selector", "blockSelector", "sourceFile", "text"] as const;
const CRAFT_RECT_NUMBERS = ["left", "top", "width", "height"] as const;
function parseCraftTextPart(value: unknown): CraftTextPart[] {
if (!isRecord(value)) return [];
const nums = requiredNumbers(value, CRAFT_PART_NUMBERS);
const strings = requiredStrings(value, CRAFT_PART_STRINGS);
const rect = recordField(value, "rect");
const box = rect ? requiredNumbers(rect, CRAFT_RECT_NUMBERS) : null;
const captions = booleanValue(value, "captions");
return nums && strings && box && captions !== null
? [{ ...nums, ...strings, rect: box, captions }]
: [];
}
function requiredStrings<K extends string>(
value: Record<string, unknown>,
keys: readonly K[],
): { [P in K]: string } | null {
const out = {} as { [P in K]: string };
for (const key of keys) {
const entry = stringValue(value, key);
if (entry === null) return null;
out[key] = entry;
}
return out;
}
async function hasNoTimelineDeclaration(page: Page): Promise<boolean> {
return page.evaluate(
() =>
+102 -15
View File
@@ -37,6 +37,7 @@ import {
type MotionFrame,
} from "./motionAudit.js";
import { findMotionSpec, readMotionSpec, type MotionAssertion } from "./motionSpec.js";
import { auditCraft } from "./craftAudit.js";
import { inspectHdrAutoPromotion } from "./hdrPromotion.js";
import { normalizeErrorMessage } from "./errorMessage.js";
import {
@@ -63,6 +64,9 @@ import type {
CheckSection,
CheckSeverity,
ContrastAuditEntry,
CraftFinding,
CraftFrame,
CraftResult,
GeometryCandidateRequest,
MotionSpecResolution,
OffPivotFrame,
@@ -282,6 +286,7 @@ interface GridSamples {
* material-point geometry + dial hub per layout sample; flattened by selector
* to detect off_pivot_rotation. */
indicatorFrames: OffPivotFrame[];
craftFrames: CraftFrame[];
}
function geometryRequest(
@@ -436,6 +441,7 @@ function emptyGridSamples(): GridSamples {
layoutStateSignatures: [],
rotationSamples: [],
indicatorFrames: [],
craftFrames: [],
};
}
@@ -449,6 +455,7 @@ function appendGridSamples(into: GridSamples, from: GridSamples): void {
into.layoutStateSignatures.push(...from.layoutStateSignatures);
into.rotationSamples.push(...from.rotationSamples);
into.indicatorFrames.push(...from.indicatorFrames);
into.craftFrames.push(...from.craftFrames);
}
interface SampleSets {
@@ -552,10 +559,20 @@ async function collectGridSamples(
driver.markSamples?.("end");
const collected = emptyGridSamples();
for (const samples of perTime) appendGridSamples(collected, samples);
await collectMotionOverlapSamples(driver, grid, collected, options.range);
await collectMotionOverlapSamples(
driver,
grid,
collected,
options.range,
craftEnabled(driver, options),
);
return collected;
}
function craftEnabled(driver: CheckAuditDriver, options: CheckOptions): boolean {
return !options.range && typeof driver.collectCraftFrame === "function";
}
async function collectContrastAt(
driver: CheckAuditDriver,
options: CheckOptions,
@@ -660,10 +677,20 @@ async function collectMotionOverlapSamples(
driver: CheckAuditDriver,
grid: SampleGrid,
collected: GridSamples,
range?: [number, number],
range: [number, number] | undefined,
craft: boolean,
): Promise<void> {
const baseTimes = new Set(grid.layoutSamples);
const shiftedTimes = await denseOverlapTimes(driver, grid, range);
// Before the grid: a seek after it would overwrite the span the grid stamps on what its samples queue.
if (craft) {
for (const time of buildCraftSampleTimes(grid.duration)) {
// The render's last frame lands before the duration, where the runtime has already unmounted clips.
if (time >= grid.duration) continue;
await driver.seekGeometry(time);
collected.craftFrames.push(...(await collectCraftFrame(driver, time)));
}
}
const probeHold = holdProber(driver, collected, range ? range[1] : grid.duration);
const gridIssues = await driver.collectOverlapGrid(shiftedTimes.filter((t) => !baseTimes.has(t)));
let sample = 0;
@@ -675,6 +702,26 @@ async function collectMotionOverlapSamples(
}
}
// Craft times how long a line shows, so it keeps 8 fps past the overlap pass's ceiling, up to ~75 s.
const CRAFT_SAMPLE_FPS = 8;
const CRAFT_MAX_SAMPLES = 600;
function buildCraftSampleTimes(duration: number): number[] {
if (!Number.isFinite(duration) || duration <= 0) return [];
const count = Math.min(
CRAFT_MAX_SAMPLES,
Math.max(2, Math.ceil(duration * CRAFT_SAMPLE_FPS) + 1),
);
const step = duration / (count - 1);
return mergeSampleTimes(
Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000),
);
}
async function collectCraftFrame(driver: CheckAuditDriver, time: number): Promise<CraftFrame[]> {
return driver.collectCraftFrame ? [await driver.collectCraftFrame(time)] : [];
}
// Frozen-sweep guard (#U10): compositions this short can legitimately hold a
// single static frame the whole time (a title card) — never flag those.
const SWEEP_STATIC_MIN_DURATION_SEC = 3;
@@ -1369,6 +1416,16 @@ function annotationBoxesFrom(issues: AnchoredLayoutIssue[]): CheckAnnotationBox[
.map((issue, index) => ({ label: `${index + 1} ${issue.code}`, bbox: issue.bbox }));
}
function wholeCompositionKeepsMoving(assertions: MotionAssertion[]): boolean {
return assertions.some(
(assertion) => assertion.kind === "keepsMoving" && assertion.withinSelector === undefined,
);
}
function disabledCraft(): CraftResult {
return { enabled: false, step: 0, samples: 0, findings: [] };
}
export async function runAuditGrid(
driver: CheckAuditDriver,
options: CheckOptions,
@@ -1389,25 +1446,21 @@ export async function runAuditGrid(
let motionIssues = plan.preflightIssues;
if (motion.kind === "valid" && plan.assertions.length > 0 && collected.motionFrames.length > 0) {
const canvas = await driver.getCanvas();
let evaluated = evaluateMotion(collected.motionFrames, plan.assertions, canvas);
let evaluated = evaluateMotion(collected.motionFrames, plan.assertions, canvas, grid.duration);
if (evaluated.some((issue) => issue.code === "motion_frozen")) {
const frames = await withFrameMotion(driver, collected.motionFrames, plan.assertions);
evaluated = evaluateMotion(frames, plan.assertions, canvas);
evaluated = evaluateMotion(frames, plan.assertions, canvas, grid.duration);
}
motionIssues = [...motionIssues, ...(await driver.anchorMotionIssues(evaluated))];
}
const userPicked = new Set(grid.userPickedSamples);
const noTimeline = await driver.hasNoTimelineDeclaration();
const sweepSamples = collected.layoutStateSignatures.filter(
(sample) => !userPicked.has(sample.time),
);
const sweepVerdict = options.range
? []
: detectSweepStatic(
grid.duration,
sweepSamples,
motionIssues,
await driver.hasNoTimelineDeclaration(),
);
: detectSweepStatic(grid.duration, sweepSamples, motionIssues, noTimeline);
const sweepFindings =
sweepVerdict.length > 0 &&
(await framesMoved(
@@ -1422,6 +1475,25 @@ export async function runAuditGrid(
);
const offPivotFindings = detectOffPivotRotation(collected.indicatorFrames);
const contrast = buildContrastResults(collected.contrastEntries);
const craft = craftEnabled(driver, options)
? await auditCraft(
{
frames: collected.craftFrames,
duration: grid.duration,
canvas: await driver.getCanvas(),
soundSources: driver.collectCraftFilm
? (await driver.collectCraftFilm()).soundSources
: null,
stillnessJudgedElsewhere:
noTimeline || sweepFindings.length > 0 || wholeCompositionKeepsMoving(plan.assertions),
},
async (time) => {
await driver.seekGeometry(time);
if (!driver.collectCraftFrame) throw new Error("craft sampling unavailable");
return driver.collectCraftFrame(time);
},
)
: disabledCraft();
if (options.components?.length) {
if (!driver.matchComponents)
throw new Error("Component filtering unavailable in this check driver");
@@ -1432,6 +1504,7 @@ export async function runAuditGrid(
...offPivotFindings,
...motionIssues,
...contrast.findings,
...craft.findings,
];
const matches = await driver.matchComponents(options.components, findings);
findings.forEach((finding, i) => {
@@ -1456,6 +1529,7 @@ export async function runAuditGrid(
contrastFindings: contrast.findings,
contrastChecked: collected.contrastEntries.length,
contrastPassed: contrast.passed,
craft,
screenshots: collected.screenshots,
timings: { launchSettleMs: 0, seekLoopMs, contrastMs: collected.contrastMs },
skipped: false,
@@ -1784,20 +1858,25 @@ function buildReport(
const runtime = section(browser.runtimeFindings);
const motionSection = section(motionFindings);
const contrastSection = section(browser.contrastFindings);
const craftSection = section<CraftFinding>(browser.craft.findings);
const warningCount =
lint.warningCount +
runtime.warningCount +
layout.warningCount +
motionSection.warningCount +
contrastSection.warningCount;
contrastSection.warningCount +
craftSection.warningCount;
const errorCount =
lint.errorCount +
runtime.errorCount +
layout.errorCount +
motionSection.errorCount +
contrastSection.errorCount;
contrastSection.errorCount +
craftSection.errorCount;
// Craft findings are judgment calls a film may break on purpose, so --strict gates defects only.
const strictWarningCount = warningCount - craftSection.warningCount;
const report: CheckReport = {
ok: errorCount === 0 && (!options.strict || warningCount === 0),
ok: errorCount === 0 && (!options.strict || strictWarningCount === 0),
strict: options.strict,
browserSkipped: browser.skipped,
lint,
@@ -1816,6 +1895,12 @@ function buildReport(
checked: browser.contrastChecked,
passed: browser.contrastPassed,
},
craft: {
...craftSection,
enabled: browser.craft.enabled,
step: browser.craft.step,
samples: browser.craft.samples,
},
hdr: { autoPromotion: hdrPromotion, inspection: hdrInspection },
snapshots: {
enabled: options.snapshots,
@@ -1840,6 +1925,7 @@ function buildReport(
motionWarnings: motionSection.warningCount,
contrastErrors: contrastSection.errorCount,
contrastWarnings: contrastSection.warningCount,
craftWarnings: craftSection.warningCount,
launchSettleMs: browser.timings.launchSettleMs,
seekLoopMs: browser.timings.seekLoopMs,
contrastMs: browser.timings.contrastMs,
@@ -1863,12 +1949,12 @@ function filterComponentReport(report: CheckReport, options: CheckOptions): Chec
"Static lint and unscoped runtime failures remain project-wide.",
...(options.range
? [
"Whole-timeline motion assertions and frozen-sweep detection are skipped for range checks.",
"Whole-timeline motion assertions, frozen-sweep detection and craft warnings are skipped for range checks.",
]
: []),
],
};
for (const group of [report.layout, report.motion, report.contrast]) {
for (const group of [report.layout, report.motion, report.contrast, report.craft]) {
const before = group.findings.length;
group.findings = group.findings.filter((f) => f.componentMatch !== false);
report.scope.hiddenFindings += before - group.findings.length;
@@ -1966,6 +2052,7 @@ function emptyBrowserResult(): CheckBrowserResult {
contrastFindings: [],
contrastChecked: 0,
contrastPassed: 0,
craft: disabledCraft(),
screenshots: [],
timings: { launchSettleMs: 0, seekLoopMs: 0, contrastMs: 0 },
skipped: true,
@@ -133,7 +133,8 @@ describe.skipIf(!RUNS_CHROMIUM)("frozen-sweep guard in Chromium", () => {
it("still warns on, and keepsMoving still fails, a film where nothing on screen moves", async () => {
const result = await findings(STILL_FILM, [{ kind: "keepsMoving" }]);
expect(result.frozen).toEqual([expect.stringContaining("nothing moves between 0s and 6s")]);
// The last END_CARD_HOLD_SEC (3-6 s) may hold still, so the frozen span ends where it starts.
expect(result.frozen).toEqual([expect.stringContaining("nothing moves between 0s and 3s")]);
// A motion_frozen finding already names the symptom, so the sweep guard stays quiet.
expect(result.sweep).toEqual([]);
expect((await findings(STILL_FILM)).sweep).toEqual(["Nothing on screen moved under seek."]);
+50
View File
@@ -185,6 +185,33 @@ export interface OffPivotFrame {
samples: OffPivotRotationSample[];
}
export interface CraftTextPart {
key: number;
/** Key of its reading block: the nearest block-level ancestor-or-self, so a split headline is one line. */
block: number;
selector: string;
blockSelector: string;
sourceFile: string;
text: string;
opacity: number;
fontPx: number;
rect: { left: number; top: number; width: number; height: number };
captions: boolean;
}
export interface CraftFrame {
time: number;
parts: CraftTextPart[];
/** A data-hold element is on screen: stillness here is deliberate. */
held: boolean;
painted: boolean;
signature: string;
}
export interface CraftFilm {
soundSources: number;
}
export type MotionSpecResolution =
| { kind: "none" }
| { kind: "valid"; path: string; spec: MotionSpec }
@@ -239,6 +266,9 @@ export interface CheckAuditDriver {
time: number,
snapshotAnnotations?: CheckAnnotationBox[],
): Promise<ContrastCapture>;
/** Craft section: text, stillness and paint at the current seek. A driver without it skips the section. */
collectCraftFrame?(time: number): Promise<CraftFrame>;
collectCraftFilm?(): Promise<CraftFilm>;
}
export interface CheckScreenshot {
@@ -265,11 +295,30 @@ export interface CheckBrowserResult {
contrastFindings: CheckContrastFinding[];
contrastChecked: number;
contrastPassed: number;
craft: CraftResult;
screenshots: CheckScreenshot[];
timings: CheckTimings;
skipped: boolean;
}
/** Taste warnings measured on the dense grid: never errors, so they gate only under --strict. */
export interface CraftFinding extends CheckFinding {
words?: number;
seconds?: number;
floorSeconds?: number;
fontPx?: number;
floorPx?: number;
until?: number;
elements?: { selector: string; text: string; fontPx: number; time: number; seconds: number }[];
}
export interface CraftResult {
enabled: boolean;
step: number;
samples: number;
findings: CraftFinding[];
}
/** The seek-grid audit loop, injected into checkBrowser so it never imports checkPipeline back. */
export type RunAuditGrid = (
driver: CheckAuditDriver,
@@ -314,6 +363,7 @@ export interface CheckReport {
checked: number;
passed: number;
};
craft: CheckSection<CraftFinding> & { enabled: boolean; step: number; samples: number };
hdr: { autoPromotion: HdrAutoPromotion | null; inspection: "available" | "unavailable" };
snapshots: { enabled: boolean; files: string[]; times: number[]; findingFiles: string[] };
}
+288
View File
@@ -0,0 +1,288 @@
import { describe, expect, it } from "vitest";
import {
auditCraft,
detectDeadStretches,
detectFilmSilent,
detectFirstFrameEmpty,
detectLineUnread,
detectTextBelowFloor,
lineFloorSec,
readingWords,
typeFloorPx,
} from "./craftAudit.js";
import type { CraftFrame, CraftTextPart } from "./checkTypes.js";
const CANVAS = { width: 1920, height: 1080 };
const STEP = 0.125;
interface Shown {
key: number;
text: string;
from: number;
to: number;
fontPx?: number;
captions?: boolean;
/** Block key; defaults to the element's own key. */
block?: number;
}
function part(shown: Shown): CraftTextPart {
const fontPx = shown.fontPx ?? 96;
return {
key: shown.key,
block: shown.block ?? shown.key,
selector: `#t${shown.key}`,
blockSelector: `#t${shown.block ?? shown.key}`,
sourceFile: "compositions/scene.html",
text: shown.text,
opacity: 1,
fontPx,
rect: { left: 100, top: 100 + shown.key * 10, width: 800, height: fontPx * 1.2 },
captions: shown.captions ?? false,
};
}
/** A frame at `time` showing every element whose [from, to) window holds it. */
function frameAt(
time: number,
shown: Shown[],
extra: Partial<Omit<CraftFrame, "time" | "parts">> = {},
): CraftFrame {
return {
time,
parts: shown.filter((entry) => time >= entry.from && time < entry.to).map(part),
held: false,
painted: true,
signature: `moving-${time}`,
...extra,
};
}
function grid(duration: number, step = STEP): number[] {
const times: number[] = [];
for (let time = 0; time < duration - 1e-9; time += step)
times.push(Math.round(time * 1000) / 1000);
return times;
}
function film(duration: number, shown: Shown[]): CraftFrame[] {
return grid(duration).map((time) => frameAt(time, shown));
}
/** An exact probe: re-samples the same scene at any time, as a seek would. */
function probeFor(shown: Shown[]) {
return async (time: number) => frameAt(time, shown);
}
describe("readingWords", () => {
it("counts letter and number runs, and CJK by dictionary word", () => {
expect(readingWords("Every team gets one shared calendar now")).toBe(7);
expect(readingWords("42%")).toBe(1);
expect(readingWords("✓ — →")).toBe(0);
expect(readingWords("今天天气很好")).toBeGreaterThan(1);
});
});
describe("lineFloorSec", () => {
it("lets a 1–2 word line flash for 0.4 s and asks 3+ words for 3/4 of 0.5 s + 0.3 s a word", () => {
expect(lineFloorSec(1, false)).toBe(0.4);
expect(lineFloorSec(2, false)).toBe(0.4);
expect(lineFloorSec(3, false)).toBeCloseTo(1.05);
expect(lineFloorSec(6, false)).toBeCloseTo(1.725);
expect(lineFloorSec(1, true)).toBe(2);
expect(lineFloorSec(12, true)).toBeCloseTo(3.075);
});
});
describe("line_unread", () => {
it("warns on a 7-word line on screen 0.7 s, measured between grid samples", async () => {
const shown: Shown[] = [
{ key: 1, text: "Every team gets one shared calendar now", from: 6.2, to: 6.9 },
{ key: 2, text: "Try it today", from: 13, to: 20 },
];
const findings = await detectLineUnread(film(20, shown), 20, CANVAS, probeFor(shown));
expect(findings).toHaveLength(1);
const [finding] = findings;
expect(finding).toMatchObject({ code: "line_unread", severity: "warning", words: 7 });
expect(finding?.seconds).toBeCloseTo(0.7, 1);
expect(finding?.floorSeconds).toBeCloseTo(1.95);
expect(finding?.message).toContain("0.5 s + 0.3 s a word");
expect(finding?.message).toContain("references/motion.md");
});
it("does not warn on a landed title held 1.5 s, a 0.4 s chapter word, captions or a 2.5 s end card", async () => {
const shown: Shown[] = [
{ key: 1, text: "Ship it faster", from: 0.5, to: 2 },
{ key: 2, text: "Chapter", from: 3, to: 3.4 },
{ key: 3, text: "fast", from: 4, to: 4.3, captions: true },
{ key: 4, text: "caption words flash past", from: 4.3, to: 4.6, captions: true },
{ key: 5, text: "Try it today", from: 7.5, to: 10 },
];
expect(await detectLineUnread(film(10, shown), 10, CANVAS, probeFor(shown))).toEqual([]);
});
it("holds the film's last line to 2 s even when it is one word", async () => {
const shown: Shown[] = [
{ key: 1, text: "Ship it faster", from: 0, to: 5 },
{ key: 2, text: "HyperFrames", from: 9, to: 10 },
];
const findings = await detectLineUnread(film(10, shown), 10, CANVAS, probeFor(shown));
expect(findings.map((finding) => finding.selector)).toEqual(["#t2"]);
expect(findings[0]?.floorSeconds).toBe(2);
expect(findings[0]?.message).toContain("last line");
});
it("reads a headline split into inline word spans as one line", async () => {
const words = ["Every", "team", "gets", "one", "shared", "calendar", "now"];
const shown: Shown[] = words.map((text, index) => ({
key: 10 + index,
block: 1,
text,
from: 2,
to: 2.6,
}));
shown.push({ key: 2, text: "End card here", from: 5, to: 8 });
const findings = await detectLineUnread(film(8, shown), 8, CANVAS, probeFor(shown));
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({ selector: "#t1", words: 7 });
});
it("skips text too small to be a reading line", async () => {
const shown: Shown[] = [
{ key: 1, text: "Legal fine print that flashes by", from: 1, to: 1.3, fontPx: 18 },
{ key: 2, text: "End card here", from: 4, to: 8 },
];
expect(await detectLineUnread(film(8, shown), 8, CANVAS, probeFor(shown))).toEqual([]);
});
});
describe("dead_stretch", () => {
const still = (from: number, to: number) => (time: number) =>
time >= from && time <= to ? "still" : `moving-${time}`;
function framesWith(
duration: number,
signature: (time: number) => string,
held: (time: number) => boolean = () => false,
) {
return grid(duration).map((time) =>
frameAt(time, [], { signature: signature(time), held: held(time) }),
);
}
it("warns on 5 s of nothing changing mid-film", () => {
const findings = detectDeadStretches(framesWith(20, still(7.5, 12.5)), 20);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({ code: "dead_stretch", time: 7.5, until: 12.5 });
expect(findings[0]?.message).toContain("data-hold");
});
it("does not warn on a 1.5 s landed title or a 3 s still end card", () => {
const signature = (time: number) =>
(time >= 2 && time <= 3.5) || time >= 17 ? "still" : `moving-${time}`;
expect(detectDeadStretches(framesWith(20, signature), 20)).toEqual([]);
});
it("leaves the final 3 s out of a long still ending", () => {
expect(detectDeadStretches(framesWith(20, still(14, 20)), 20)).toEqual([]);
const findings = detectDeadStretches(framesWith(20, still(12.5, 20)), 20);
expect(findings).toHaveLength(1);
expect(findings[0]?.until).toBe(17);
});
it("does not count a stretch where a data-hold element is on screen", () => {
const held = (time: number) => time >= 7 && time < 13;
expect(detectDeadStretches(framesWith(20, still(7.5, 12.5), held), 20)).toEqual([]);
});
it("defers to sweep_static and keepsMoving when the pipeline says stillness is judged elsewhere", async () => {
const result = await auditCraft({
frames: framesWith(20, still(7.5, 12.5)),
duration: 20,
canvas: CANVAS,
soundSources: 1,
stillnessJudgedElsewhere: true,
});
expect(result.findings.map((finding) => finding.code)).not.toContain("dead_stretch");
});
});
describe("text_below_floor", () => {
it("puts the floor at 2.6% of the short side", () => {
expect(typeFloorPx(CANVAS)).toBe(28);
expect(typeFloorPx({ width: 1080, height: 1920 })).toBe(28);
expect(typeFloorPx({ width: 3840, height: 2160 })).toBe(56);
});
it("warns once per file on small text held 0.5 s or more", () => {
const shown: Shown[] = [
{ key: 1, text: "v2.4 release notes", from: 0.5, to: 6, fontPx: 18 },
{ key: 2, text: "Q1", from: 1, to: 6, fontPx: 14 },
];
const findings = detectTextBelowFloor(film(10, shown), 10, CANVAS);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({ code: "text_below_floor", fontPx: 14, floorPx: 28 });
expect(findings[0]?.elements).toHaveLength(2);
expect(findings[0]?.message).toContain("references/look.md");
});
it("does not warn on 28 px text, a brief small flash, captions or symbols", () => {
const shown: Shown[] = [
{ key: 1, text: "Body copy at the floor", from: 0, to: 10, fontPx: 28 },
{ key: 2, text: "tiny flash", from: 2, to: 2.3, fontPx: 16 },
{ key: 3, text: "caption words", from: 0, to: 10, fontPx: 20, captions: true },
{ key: 4, text: "✓", from: 0, to: 10, fontPx: 8 },
];
expect(detectTextBelowFloor(film(10, shown), 10, CANVAS)).toEqual([]);
});
});
describe("first_frame_empty", () => {
it("warns when frame 0 paints nothing but the background", () => {
const findings = detectFirstFrameEmpty([
frameAt(0, [], { painted: false }),
frameAt(0.125, []),
]);
expect(findings).toHaveLength(1);
expect(findings[0]?.message).toContain("thumbnail");
expect(findings[0]?.message).toContain("references/review.md");
});
it("does not warn when the title is already on screen at 0 s", () => {
expect(detectFirstFrameEmpty([frameAt(0, [], { painted: true })])).toEqual([]);
expect(detectFirstFrameEmpty([frameAt(0.5, [], { painted: false })])).toEqual([]);
});
});
describe("film_silent", () => {
it("warns on a film over 15 s with no sound source", () => {
const [finding] = detectFilmSilent(20, 0);
expect(finding?.code).toBe("film_silent");
expect(finding?.message).toContain("unless the person asked for silence");
expect(finding?.message).toContain("references/sound.md");
});
it("does not warn on a short film, a film with sound, or when sound was not inspected", () => {
expect(detectFilmSilent(12, 0)).toEqual([]);
expect(detectFilmSilent(20, 1)).toEqual([]);
expect(detectFilmSilent(20, null)).toEqual([]);
});
});
describe("auditCraft", () => {
it("reports nothing on a well-made film", async () => {
const shown: Shown[] = [
{ key: 1, text: "Ship faster with fewer meetings", from: 0, to: 5.8 },
{ key: 2, text: "42% fewer meetings", from: 6.3, to: 12.8 },
{ key: 3, text: "Try it today", from: 13.2, to: 20 },
];
const signature = (time: number) =>
(time >= 1 && time <= 2.5) || time >= 17 ? "still" : `moving-${time}`;
const frames = grid(20).map((time) => frameAt(time, shown, { signature: signature(time) }));
const result = await auditCraft(
{ frames, duration: 20, canvas: CANVAS, soundSources: 2, stillnessJudgedElsewhere: false },
probeFor(shown),
);
expect(result).toMatchObject({ enabled: true, step: STEP, samples: 160, findings: [] });
});
});
+541
View File
@@ -0,0 +1,541 @@
import { END_CARD_HOLD_SEC, type Canvas } from "./motionAudit.js";
import type { CheckBbox, CraftFinding, CraftFrame, CraftResult } from "./checkTypes.js";
/**
* Craft thresholds. Line holds follow the skills' target of 0.5 s + 0.3 s a word and warn
* at three quarters of it; a one- or two-word chapter line may flash for 0.4 s, as the
* official films do. Heights and sizes scale with the frame's short side (1080 px reference).
*/
const CRAFT_THRESHOLDS = {
readHeightAt1080: 30,
readOpacity: 0.5,
shortLineWords: 2,
shortLineFloorSec: 0.4,
holdBaseSec: 0.5,
holdPerWordSec: 0.3,
holdFloorFraction: 0.75,
finalLineFloorSec: 2,
deadStretchSec: 3,
endCardSec: END_CARD_HOLD_SEC,
typeFloorFraction: 0.026,
typeFloorHoldSec: 0.5,
silentFilmSec: 15,
/** One video frame: a line held exactly at its floor measures a hair under it. */
measureToleranceSec: 1 / 30,
/** Halvings of a grid step when re-measuring a line's edges (0.125 s → ~8 ms). */
refineSteps: 4,
maxRefinedLines: 24,
} as const;
const T = CRAFT_THRESHOLDS;
const ZERO_BBOX: CheckBbox = { x: 0, y: 0, width: 0, height: 0 };
const WORD_RUNS = /[\p{L}\p{M}\p{N}%$]+/gu;
// Chinese and Japanese have no spaces; a dictionary segmenter counts their words like an English line's.
const WORD_SEGMENTER = new Intl.Segmenter("ja", { granularity: "word" });
/** Words a viewer reads: letter/number runs, with CJK runs split into dictionary words. */
export function readingWords(text: string): number {
const runs = text.toLowerCase().normalize("NFC").match(WORD_RUNS) ?? [];
return runs.reduce(
(count, run) =>
count + Math.max(1, [...WORD_SEGMENTER.segment(run)].filter((s) => s.isWordLike).length),
0,
);
}
function lineTargetSec(words: number): number {
return T.holdBaseSec + T.holdPerWordSec * words;
}
/** Shortest hold that does not warn; the film's last line needs at least 2 s. */
export function lineFloorSec(words: number, finalLine: boolean): number {
const floor =
words <= T.shortLineWords ? T.shortLineFloorSec : T.holdFloorFraction * lineTargetSec(words);
return finalLine ? Math.max(floor, T.finalLineFloorSec) : floor;
}
export interface CraftAuditInput {
frames: CraftFrame[];
duration: number;
canvas: Canvas;
soundSources: number | null;
/** sweep_static fired, a whole-composition keepsMoving owns stillness, or the root says data-no-timeline. */
stillnessJudgedElsewhere: boolean;
}
/** Seeks to `time` and samples it again, for re-measuring a line's edges between grid samples. */
export type CraftProbe = (time: number) => Promise<CraftFrame>;
export async function auditCraft(input: CraftAuditInput, probe?: CraftProbe): Promise<CraftResult> {
const frames = [...input.frames].sort((a, b) => a.time - b.time);
const step = sampleStep(frames);
const findings: CraftFinding[] = [
...detectFirstFrameEmpty(frames),
...(await detectLineUnread(frames, input.duration, input.canvas, probe)),
...(input.stillnessJudgedElsewhere ? [] : detectDeadStretches(frames, input.duration)),
...detectTextBelowFloor(frames, input.duration, input.canvas),
...detectFilmSilent(input.duration, input.soundSources),
].sort((a, b) => a.time - b.time);
return { enabled: true, step, samples: frames.length, findings };
}
function sampleStep(frames: CraftFrame[]): number {
const gaps = frames
.slice(1)
.map((frame, index) => frame.time - (frames[index]?.time ?? frame.time))
.filter((gap) => gap > 0)
.sort((a, b) => a - b);
return gaps[Math.floor(gaps.length / 2)] ?? 0;
}
function seconds(value: number): string {
return `${value.toFixed(2)} s`;
}
function filmFinding(
code: CraftFinding["code"],
time: number,
message: string,
extra: Partial<CraftFinding> = {},
): CraftFinding {
return {
code,
severity: "warning",
message,
selector: "[data-composition-id]",
dataAttributes: {},
sourceFile: "index.html",
bbox: ZERO_BBOX,
time,
...extra,
};
}
// ---------- first_frame_empty ----------
export function detectFirstFrameEmpty(frames: CraftFrame[]): CraftFinding[] {
const first = frames[0];
if (!first || first.time > 1e-3 || first.painted) return [];
return [
filmFinding(
"first_frame_empty",
0,
"Frame 0 shows only the background, and the first frame is the thumbnail on social players: have the title or key image already on screen at 0 s instead of entering from nothing; see references/review.md.",
),
];
}
// ---------- film_silent ----------
export function detectFilmSilent(duration: number, soundSources: number | null): CraftFinding[] {
if (soundSources === null || soundSources > 0 || duration <= T.silentFilmSec) return [];
return [
filmFinding(
"film_silent",
0,
`This ${Math.round(duration)} s film has no sound source (no <audio>, no <video> with sound): add a music bed and sounds on the visible actions unless the person asked for silence; see references/sound.md.`,
),
];
}
// ---------- dead_stretch ----------
/** Runs of identical liveness signatures longer than 3 s, outside the end card and data-hold. */
export function detectDeadStretches(frames: CraftFrame[], duration: number): CraftFinding[] {
const endCardStart = duration - T.endCardSec;
const findings: CraftFinding[] = [];
let start: number | null = null;
let end = 0;
const flush = () => {
if (start !== null && end - start > T.deadStretchSec) findings.push(deadStretch(start, end));
start = null;
};
for (let index = 1; index < frames.length; index += 1) {
const before = frames[index - 1];
const after = frames[index];
if (!before || !after) continue;
const still =
before.signature !== "" &&
before.signature === after.signature &&
!before.held &&
!after.held &&
before.time < endCardStart;
if (!still) {
flush();
continue;
}
start ??= before.time;
end = Math.min(after.time, endCardStart);
}
flush();
return findings;
}
function deadStretch(start: number, end: number): CraftFinding {
const length = end - start;
return filmFinding(
"dead_stretch",
start,
`Nothing on screen changes for ${seconds(length)} (${start.toFixed(2)}–${end.toFixed(2)} s): start the next move within ${T.deadStretchSec} s, or put data-hold on the element whose stillness is deliberate; see references/motion.md.`,
{ seconds: round(length), floorSeconds: T.deadStretchSec, until: round(end) },
);
}
// ---------- line_unread ----------
interface BlockFrame {
text: string;
rect: CraftRect;
}
interface CraftRect {
left: number;
top: number;
right: number;
bottom: number;
}
interface BlockTrack {
key: number;
selector: string;
sourceFile: string;
/** Frame index → what the block showed there. */
seen: Map<number, BlockFrame>;
}
/** Readable blocks in one frame: non-caption parts at ≥ 0.5 opacity whose union is reading-sized. */
function readableBlocks(frame: CraftFrame, readHeight: number): Map<number, BlockFrame & Anchor> {
const blocks = new Map<number, BlockFrame & Anchor>();
for (const part of frame.parts) {
if (part.captions || part.opacity < T.readOpacity) continue;
const rect = {
left: part.rect.left,
top: part.rect.top,
right: part.rect.left + part.rect.width,
bottom: part.rect.top + part.rect.height,
};
const block = blocks.get(part.block);
if (!block) {
blocks.set(part.block, {
text: part.text,
rect,
selector: part.blockSelector,
sourceFile: part.sourceFile,
});
continue;
}
block.text = `${block.text} ${part.text}`;
block.rect = {
left: Math.min(block.rect.left, rect.left),
top: Math.min(block.rect.top, rect.top),
right: Math.max(block.rect.right, rect.right),
bottom: Math.max(block.rect.bottom, rect.bottom),
};
}
for (const [key, block] of blocks) {
if (block.rect.bottom - block.rect.top < readHeight) blocks.delete(key);
}
return blocks;
}
interface Anchor {
selector: string;
sourceFile: string;
}
function trackBlocks(frames: CraftFrame[], readHeight: number): BlockTrack[] {
const tracks = new Map<number, BlockTrack>();
frames.forEach((frame, index) => {
for (const [key, block] of readableBlocks(frame, readHeight)) {
const track = tracks.get(key) ?? {
key,
selector: block.selector,
sourceFile: block.sourceFile,
seen: new Map<number, BlockFrame>(),
};
track.seen.set(index, { text: block.text, rect: block.rect });
tracks.set(key, track);
}
});
return [...tracks.values()];
}
/** Maximal runs of consecutive frame indices. */
function indexRuns(indices: number[]): [number, number][] {
const sorted = [...indices].sort((a, b) => a - b);
const runs: [number, number][] = [];
for (const index of sorted) {
const last = runs[runs.length - 1];
if (last && index === last[1] + 1) last[1] = index;
else runs.push([index, index]);
}
return runs;
}
/** A line's on-screen window: it starts in (startOut, startIn] and ends in [endIn, endOut). */
interface LineWindow {
startOut: number;
startIn: number;
endIn: number;
endOut: number;
}
function windowSeconds(window: LineWindow): number {
return (window.endIn + window.endOut) / 2 - (window.startOut + window.startIn) / 2;
}
interface LineCandidate {
track: BlockTrack;
words: number;
text: string;
finalLine: boolean;
floor: number;
window: LineWindow;
firstIndex: number;
}
/** A run that reaches the last sample lasts to the film's end; one at sample 0 starts exactly there. */
function runWindow(
frames: CraftFrame[],
[first, last]: [number, number],
duration: number,
): LineWindow {
const timeAt = (index: number) =>
frames[Math.min(Math.max(index, 0), frames.length - 1)]?.time ?? 0;
const reachesEnd = last === frames.length - 1;
return {
startOut: timeAt(first - 1),
startIn: timeAt(first),
endIn: reachesEnd ? duration : timeAt(last),
endOut: reachesEnd ? duration : timeAt(last + 1),
};
}
function lineCandidates(frames: CraftFrame[], duration: number, canvas: Canvas): LineCandidate[] {
const readHeight = T.readHeightAt1080 * (Math.min(canvas.width, canvas.height) / 1080);
const tracks = trackBlocks(frames, readHeight);
const lastEnd = (track: BlockTrack) => Math.max(...track.seen.keys());
const latest = Math.max(-1, ...tracks.map(lastEnd));
const candidates: LineCandidate[] = [];
for (const track of tracks) {
const runs = indexRuns([...track.seen.keys()]);
const finalLine = lastEnd(track) === latest;
const windows = runs.map((run) => runWindow(frames, run, duration));
const chosen = finalLine
? windows.length - 1
: windows.reduce((best, window, index) => {
const bestWindow = windows[best];
return bestWindow && windowSeconds(window) > windowSeconds(bestWindow) ? index : best;
}, 0);
const window = windows[chosen];
const run = runs[chosen];
if (!window || !run) continue;
const texts = [...track.seen.values()].map((seen) => seen.text);
const text = texts.reduce((longest, next) =>
readingWords(next) > readingWords(longest) ? next : longest,
);
const words = readingWords(text);
if (words === 0) continue;
const floor = lineFloorSec(words, finalLine);
// Clearly long enough even at the shortest the grid allows.
if (window.endIn - window.startIn >= floor) continue;
candidates.push({ track, words, text, finalLine, floor, window, firstIndex: run[0] });
}
return candidates;
}
async function refineWindow(
candidate: LineCandidate,
probe: CraftProbe,
canvas: Canvas,
): Promise<LineWindow> {
const readHeight = T.readHeightAt1080 * (Math.min(canvas.width, canvas.height) / 1080);
const visibleAt = async (time: number) =>
readableBlocks(await probe(time), readHeight).has(candidate.track.key);
const window = { ...candidate.window };
for (let step = 0; step < T.refineSteps; step += 1) {
if (window.startIn > window.startOut) {
const mid = (window.startOut + window.startIn) / 2;
if (await visibleAt(mid)) window.startIn = mid;
else window.startOut = mid;
}
if (window.endOut > window.endIn) {
const mid = (window.endIn + window.endOut) / 2;
if (await visibleAt(mid)) window.endIn = mid;
else window.endOut = mid;
}
}
return window;
}
export async function detectLineUnread(
frames: CraftFrame[],
duration: number,
canvas: Canvas,
probe?: CraftProbe,
): Promise<CraftFinding[]> {
const findings: CraftFinding[] = [];
const candidates = lineCandidates(frames, duration, canvas);
for (const [index, candidate] of candidates.entries()) {
const window =
probe && index < T.maxRefinedLines
? await refineWindow(candidate, probe, canvas)
: candidate.window;
const held = windowSeconds(window);
if (held >= candidate.floor - T.measureToleranceSec) continue;
const error = Math.max(window.startIn - window.startOut, window.endOut - window.endIn) / 2;
findings.push(lineUnreadFinding(candidate, window, held, error));
}
return findings;
}
function lineUnreadFinding(
candidate: LineCandidate,
window: LineWindow,
held: number,
error: number,
): CraftFinding {
const { track, words, text, finalLine, floor } = candidate;
const quoted = quote(text, 60);
const wordLabel = `${words} word${words === 1 ? "" : "s"}`;
const measured = `${seconds(held)} (±${Math.max(0.01, error).toFixed(2)} s)`;
const target = lineTargetSec(words);
const message = finalLine
? `The film's last line ${quoted} (${wordLabel}) is on screen ${measured}, under the ${seconds(floor)} a closing line needs: hold it about 0.5 s + 0.3 s a word and at least 2 s before the film ends; see references/motion.md.`
: `${quoted} (${wordLabel}) is on screen ${measured}, under its ${seconds(floor)} floor: hold it about 0.5 s + 0.3 s a word (${seconds(target)} here) before it leaves or changes; see references/motion.md.`;
const firstSeen = track.seen.get(candidate.firstIndex);
const rect = firstSeen?.rect;
return {
code: "line_unread",
severity: "warning",
message,
text,
selector: track.selector,
dataAttributes: {},
sourceFile: track.sourceFile,
bbox: rect
? { x: rect.left, y: rect.top, width: rect.right - rect.left, height: rect.bottom - rect.top }
: ZERO_BBOX,
time: round(window.startIn),
words,
seconds: round(held),
floorSeconds: round(floor),
};
}
// ---------- text_below_floor ----------
interface SmallText {
selector: string;
sourceFile: string;
text: string;
fontPx: number;
seconds: number;
time: number;
bbox: CheckBbox;
}
/** 2.6% of the short side, rounded: 28 px on 1080. */
export function typeFloorPx(canvas: Canvas): number {
return Math.round(T.typeFloorFraction * Math.min(canvas.width, canvas.height));
}
export function detectTextBelowFloor(
frames: CraftFrame[],
duration: number,
canvas: Canvas,
): CraftFinding[] {
const floorPx = typeFloorPx(canvas);
const step = sampleStep(frames);
const sightings = new Map<number, Map<number, CraftFrame["parts"][number]>>();
frames.forEach((frame, index) => {
for (const part of frame.parts) {
if (part.captions || part.opacity < T.readOpacity) continue;
// Half a pixel of slack: a scaled 28 px line measures 27.9.
if (part.fontPx >= floorPx - 0.5 || readingWords(part.text) === 0) continue;
const seen = sightings.get(part.key) ?? new Map();
seen.set(index, part);
sightings.set(part.key, seen);
}
});
const small: SmallText[] = [];
for (const seen of sightings.values()) {
const [first, last] = longestRun(indexRuns([...seen.keys()]));
const start = frames[first]?.time ?? 0;
const end = last === frames.length - 1 ? duration : (frames[last]?.time ?? start) + step;
if (end - start < T.typeFloorHoldSec) continue;
const parts = [...seen.values()];
const part = parts[0];
if (!part) continue;
small.push({
selector: part.selector,
sourceFile: part.sourceFile,
text: part.text,
fontPx: Math.min(...parts.map((entry) => entry.fontPx)),
seconds: end - start,
time: start,
bbox: {
x: part.rect.left,
y: part.rect.top,
width: part.rect.width,
height: part.rect.height,
},
});
}
const shortSide = Math.min(canvas.width, canvas.height);
return groupBySourceFile(small).map((group) => smallTextFinding(group, floorPx, shortSide));
}
function longestRun(runs: [number, number][]): [number, number] {
return runs.reduce((best, run) => (run[1] - run[0] > best[1] - best[0] ? run : best), [0, -1]);
}
/** One finding per file (a scene, with sub-compositions), anchored on its smallest text. */
function groupBySourceFile(small: SmallText[]): SmallText[][] {
const groups = new Map<string, SmallText[]>();
for (const entry of small)
groups.set(entry.sourceFile, [...(groups.get(entry.sourceFile) ?? []), entry]);
return [...groups.values()].map((group) =>
group.sort((a, b) => a.fontPx - b.fontPx || a.time - b.time),
);
}
function quote(text: string, max: number): string {
return `"${text.length > max ? `${text.slice(0, max - 3)}...` : text}"`;
}
function smallTextFinding(group: SmallText[], floorPx: number, shortSide: number): CraftFinding {
const [smallest] = group;
if (!smallest) throw new Error("text_below_floor group is empty");
const subject =
group.length === 1
? `${quote(smallest.text, 48)} renders at ${Math.round(smallest.fontPx)} px for ${seconds(smallest.seconds)}`
: `${group.length} text elements stay under it for 0.5 s or more, the smallest ${quote(smallest.text, 40)} at ${Math.round(smallest.fontPx)} px`;
const byTime = [...group].sort((a, b) => a.time - b.time);
return {
code: "text_below_floor",
severity: "warning",
message: `Type floor is ${floorPx} px (2.6% of the ${shortSide} px short side, what a phone can read) and ${subject}: set reading text to at least ${floorPx} px, or put data-layout-ignore on text that is texture rather than reading; see references/look.md.`,
text: smallest.text,
selector: smallest.selector,
dataAttributes: {},
sourceFile: smallest.sourceFile,
bbox: smallest.bbox,
time: round(smallest.time),
times: byTime.map((entry) => round(entry.time)),
fontPx: round(smallest.fontPx),
floorPx,
seconds: round(smallest.seconds),
elements: group.map((entry) => ({
selector: entry.selector,
text: entry.text,
fontPx: round(entry.fontPx),
time: round(entry.time),
seconds: round(entry.seconds),
})),
};
}
function round(value: number): number {
return Math.round(value * 1000) / 1000;
}
@@ -159,6 +159,23 @@ describe("keepsMoving", () => {
expect(issue.selector).toBe(".scene");
});
it("leaves a 3 s still end card out of the static run when the film's duration is known", () => {
const assertion: MotionAssertion = { kind: "keepsMoving" };
const times = Array.from({ length: 21 }, (_, index) => index);
const endCard = frames(times, (t) => ({ liveness: { "*": t >= 17 ? "card" : `m-${t}` } }));
expect(evaluateMotion(endCard, [assertion], CANVAS)).toHaveLength(1);
expect(evaluateMotion(endCard, [assertion], CANVAS, 20)).toEqual([]);
});
it("still flags stillness that starts before the end card", () => {
const assertion: MotionAssertion = { kind: "keepsMoving" };
const times = Array.from({ length: 21 }, (_, index) => index);
const longHold = frames(times, (t) => ({ liveness: { "*": t >= 14 ? "card" : `m-${t}` } }));
const issue = expectOne(evaluateMotion(longHold, [assertion], CANVAS, 20));
expect(issue.code).toBe("motion_frozen");
expect(issue.message).toContain("between 14s and 17s");
});
it("flags a missing withinSelector instead of reporting it frozen", () => {
const assertion: MotionAssertion = { kind: "keepsMoving", withinSelector: ".nope" };
const f = frames([0, 1, 2], () => ({ liveness: { "*": "moving" } }));
+12 -2
View File
@@ -7,6 +7,7 @@ const APPEAR_OPACITY = 0.5;
const FRAME_TOLERANCE = 1;
/** Default longest allowed fully-static window for keepsMoving, in seconds. */
export const DEFAULT_MAX_STATIC_SEC = 2;
export const END_CARD_HOLD_SEC = 3;
export interface FrameSample {
rect: LayoutRect;
@@ -165,13 +166,15 @@ export function frozenSpans(
frames: MotionFrame[],
scope: string,
maxStaticSec: number,
endCardStart = Number.POSITIVE_INFINITY,
): { start: number; end: number }[] {
const spans: { start: number; end: number }[] = [];
const first = frames[0];
if (!first) return spans;
let runStart = first.time;
let runSig = first.liveness[scope] ?? "";
const flush = (end: number) => {
const flush = (runEnd: number) => {
const end = Math.min(runEnd, endCardStart);
if (end - runStart > maxStaticSec) spans.push({ start: runStart, end });
};
@@ -193,13 +196,14 @@ function keepsMoving(
frames: MotionFrame[],
within: string | undefined,
maxStaticSec: number,
endCardStart: number,
): LayoutIssue[] {
// "*" is reserved for whole-canvas scope; motionSpec.ts rejects it as a user-supplied withinSelector.
const scope = within ?? "*";
if (within && frames.every((frame) => !frame.liveness[scope])) {
return [missingIssue(within, 0)];
}
return frozenSpans(frames, scope, maxStaticSec).map(({ start, end }) => ({
return frozenSpans(frames, scope, maxStaticSec, endCardStart).map(({ start, end }) => ({
code: "motion_frozen",
severity: "error",
time: start,
@@ -219,8 +223,13 @@ export function evaluateMotion(
frames: MotionFrame[],
assertions: MotionAssertion[],
canvas: Canvas,
filmDuration?: number,
): LayoutIssue[] {
if (frames.length === 0) return [];
const endCardStart =
filmDuration !== undefined && filmDuration > 0
? filmDuration - END_CARD_HOLD_SEC
: Number.POSITIVE_INFINITY;
return assertions.flatMap((assertion) => {
switch (assertion.kind) {
case "appearsBy":
@@ -234,6 +243,7 @@ export function evaluateMotion(
frames,
assertion.withinSelector,
assertion.maxStaticSec ?? DEFAULT_MAX_STATIC_SEC,
endCardStart,
);
}
});
+1 -1
View File
@@ -60,7 +60,7 @@ export interface ProjectConfig {
/** Media handling options (e.g. auto-proxying of browser-hostile codecs). */
media?: ProjectConfigMedia;
/**
* Owning authoring-workflow skill slug (e.g. "product-launch-video"). Stamped
* Owning authoring skill slug (e.g. "hyperframes"). Stamped
* by `hyperframes init --skill` or seeded from the first `hyperframes render
* --skill`, then read back so every later render of this project — re-render,
* `npm run render`, `--batch`, preview — is attributed to it on anonymous
+106 -8
View File
@@ -24,6 +24,8 @@ import {
MANIFEST_FILE,
presentSkills,
pruneOrphanedLockEntries,
RETIRED_SKILLS,
retiredSkillNotice,
skillsAttributedToSource,
type SkillsManifest,
type SkillEntry,
@@ -136,12 +138,12 @@ describe("buildManifest", () => {
describe("isCoreSkill", () => {
it("classifies the entry router, hyperframes-* domain skills, and media-use as core", () => {
expect(isCoreSkill("hyperframes")).toBe(true);
expect(isCoreSkill("hyperframes-core")).toBe(true);
expect(isCoreSkill("hyperframes-animation")).toBe(true);
expect(isCoreSkill("hyperframes-cli")).toBe(true);
expect(isCoreSkill("hyperframes-registry")).toBe(true);
expect(isCoreSkill("media-use")).toBe(true);
// End-user workflows and optional integrations install on demand.
expect(isCoreSkill("pr-to-video")).toBe(false);
expect(isCoreSkill("embedded-captions")).toBe(false);
// Decks, ports and optional integrations install on demand.
expect(isCoreSkill("slideshow")).toBe(false);
expect(isCoreSkill("remotion-to-hyperframes")).toBe(false);
expect(isCoreSkill("figma")).toBe(false);
});
});
@@ -279,7 +281,7 @@ describe("diffSkills", () => {
source: "test",
skills: {
hyperframes: { hash: "e1", files: 1 }, // core: entry router
"pr-to-video": { hash: "w1", files: 1 }, // on-demand workflow
slideshow: { hash: "w1", files: 1 }, // on-demand workflow
},
};
@@ -294,7 +296,7 @@ describe("diffSkills", () => {
});
// Core itself missing → every workflow needs it, so that IS an update.
const coreMissing = diffSkills({ "pr-to-video": { hash: "w1", files: 1 } }, withCore);
const coreMissing = diffSkills({ slideshow: { hash: "w1", files: 1 } }, withCore);
expect(coreMissing.updateAvailable).toBe(true);
expect(coreMissing.summary).toEqual({ current: 1, outdated: 0, missing: 1, coreMissing: 1 });
});
@@ -309,7 +311,7 @@ describe("presentSkills", () => {
mkdirSync(join(skillsDir, "hyperframes"), { recursive: true });
writeFileSync(join(skillsDir, "hyperframes", "SKILL.md"), "# hyperframes");
expect(presentSkills(["hyperframes", "pr-to-video"], { cwd: project, home })).toEqual([
expect(presentSkills(["hyperframes", "slideshow"], { cwd: project, home })).toEqual([
"hyperframes",
]);
});
@@ -625,6 +627,102 @@ describe("checkSkills removed-upstream detection", () => {
expect(res.summary.removed).toBe(0);
});
// Retired skills (folded into guide folders of a remaining skill) are ours by
// name, so they are removed even where the generic rule fails safe: a lock
// entry with no skillPath, or a bundle on disk with no lock entry at all.
describe("retired skills", () => {
const retired = "hyperframes-core";
it("is one of RETIRED_SKILLS and absent from the skills/ tree", () => {
expect(RETIRED_SKILLS).toContain(retired);
const skillsRoot = join(dirname(fileURLToPath(import.meta.url)), "../../../../skills");
for (const name of RETIRED_SKILLS) expect(existsSync(join(skillsRoot, name))).toBe(false);
});
it("flags a retired skill whose lock entry has no skillPath", async () => {
const { home, opts } = setup({ source: "test", skills: { alpha: { hash: "x", files: 1 } } });
writeFileSync(
join(home, ".agents/.skill-lock.json"),
JSON.stringify({
version: 3,
skills: { alpha: { source: "test" }, [retired]: { source: "test" } },
}),
);
const res = await checkSkills(opts);
expect(res.skills.filter((s) => s.status === "removed").map((s) => s.name)).toEqual([
retired,
]);
});
it("flags a retired bundle on disk that no lock attributes", async () => {
const { home, opts } = setup({ source: "test", skills: { alpha: { hash: "x", files: 1 } } });
installSkills(join(home, ".agents/skills"), [retired]);
const res = await checkSkills(opts);
expect(res.skills.filter((s) => s.status === "removed").map((s) => s.name)).toEqual([
retired,
]);
expect(res.updateAvailable).toBe(true);
});
it("lists the eight retired workflow skills", () => {
for (const name of [
"embedded-captions",
"faceless-explainer",
"general-video",
"motion-graphics",
"music-to-video",
"pr-to-video",
"product-launch-video",
"talking-head-recut",
])
expect(RETIRED_SKILLS).toContain(name);
});
it("flags a retired workflow the lock attributes to us, even without a skillPath", async () => {
const { home, opts } = setup({ source: "test", skills: { alpha: { hash: "x", files: 1 } } });
installSkills(join(home, ".agents/skills"), ["general-video"]);
writeGlobalLock(home, { alpha: { source: "test" }, "general-video": { source: "test" } });
const res = await checkSkills(opts);
expect(res.skills.filter((s) => s.status === "removed").map((s) => s.name)).toEqual([
"general-video",
]);
});
it("leaves a same-named workflow bundle alone when no lock attributes it to us", async () => {
// Workflow names are generic: another source's `motion-graphics` must survive.
const { home, opts } = setup({ source: "test", skills: { alpha: { hash: "x", files: 1 } } });
installSkills(join(home, ".agents/skills"), ["motion-graphics"]);
const res = await checkSkills(opts);
expect(res.summary.removed).toBe(0);
});
it("explains where a retired skill's knowledge lives", () => {
expect(retiredSkillNotice("pr-to-video")).toMatch(/\/hyperframes skill and its references/);
expect(retiredSkillNotice("hyperframes-audio")).toMatch(/\/media-use skill/);
});
it("leaves a retired name alone while the manifest still publishes it", async () => {
const { home, opts } = setup({
source: "test",
skills: { alpha: { hash: "x", files: 1 }, [retired]: { hash: "y", files: 1 } },
});
installSkills(join(home, ".agents/skills"), [retired]);
writeGlobalLock(home, { alpha: { source: "test" }, [retired]: { source: "test" } });
const res = await checkSkills(opts);
expect(res.summary.removed).toBe(0);
});
});
it("a removed skill alone makes an update available (no outdated/missing)", async () => {
// Manifest lists only alpha, with its REAL hash → "current" (not outdated).
const { home, opts } = setup({ source: "test", skills: {} });
+73 -22
View File
@@ -118,22 +118,20 @@ const FETCH_TIMEOUT_MS = 4000;
//
// Two tiers decide what installs eagerly vs on demand:
//
// core — the `/hyperframes` entry router plus the shared domain skills
// (`hyperframes-*`, `media-use`) that every creation workflow
// references structurally (sibling `../hyperframes-animation/…`
// paths, "call /media-use" preambles). These must be present and
// current for ANY workflow to run, so `init` / `skills update`
// keep them fresh.
// on-demand — everything else: the end-user workflow skills (pr-to-video,
// embedded-captions, …) and optional integrations (figma). They
// install lazily, when their workflow is actually triggered
// (`hyperframes skills update <name>`), instead of being sprayed
// onto every machine that runs `init`.
// core — the `/hyperframes` entry skill plus the shared skills
// (`hyperframes-*`, `media-use`) every other skill references
// structurally (sibling `../hyperframes/references/…` paths,
// "call /media-use" preambles). These must be present and current
// for any video work, so `init` / `skills update` keep them fresh.
// on-demand — everything else: decks (slideshow), Remotion ports
// (remotion-to-hyperframes) and optional integrations (figma).
// They install when named (`hyperframes skills update <name>`),
// instead of being sprayed onto every machine that runs `init`.
/** The entry/router skill — the capability map that routes every request. */
/** The entry skill — read first for any video request. */
const ENTRY_SKILL = "hyperframes";
/** True for skills every workflow depends on (see "Skill tiers" above). */
/** True for skills every other skill depends on (see "Skill tiers" above). */
export function isCoreSkill(name: string): boolean {
return name === ENTRY_SKILL || name.startsWith("hyperframes-") || name === "media-use";
}
@@ -149,17 +147,61 @@ export function isCoreSkill(name: string): boolean {
*/
export const FALLBACK_CORE_SKILLS: readonly string[] = [
"hyperframes",
"hyperframes-cli",
"hyperframes-registry",
"media-use",
];
/**
* Domain skills folded into the `hyperframes` and `media-use` skills. Their
* `hyperframes-` prefix makes them ours by name, so an on-disk bundle with no
* lock entry is removed too.
*/
const RETIRED_DOMAIN_SKILLS: readonly string[] = [
"hyperframes-animation",
"hyperframes-audio",
"hyperframes-cli",
"hyperframes-core",
"hyperframes-creative",
"hyperframes-keyframes",
"hyperframes-registry",
"hyperframes-studio",
"media-use",
];
/**
* Workflow skills retired when their knowledge moved into the `hyperframes`
* skill's references (film, captions, overlays, motion, look). Their names are
* generic, so only a lock entry attributing them to our source marks them ours.
*/
const RETIRED_WORKFLOW_SKILLS: readonly string[] = [
"embedded-captions",
"faceless-explainer",
"general-video",
"motion-graphics",
"music-to-video",
"pr-to-video",
"product-launch-video",
"talking-head-recut",
];
/**
* Skills no longer published. Machines that installed them still carry the old
* bundles; detectRemoved flags these names whenever the manifest no longer
* lists them, so `skills update` removes them, and naming one in
* `skills update <name>` explains where its knowledge went instead of failing.
*/
export const RETIRED_SKILLS: readonly string[] = [
...RETIRED_DOMAIN_SKILLS,
...RETIRED_WORKFLOW_SKILLS,
];
/** Where a retired skill's knowledge lives now, for `skills update <retired-name>`. */
export function retiredSkillNotice(name: string): string {
const home =
name === "hyperframes-audio"
? "the /media-use skill (references/mixing.md)"
: "the /hyperframes skill and its references (film, captions, overlays, motion, look, format)";
return `${name} was retired; its knowledge now lives in ${home}, which \`skills update\` keeps current.`;
}
// ── Hashing ────────────────────────────────────────────────────────────────
// By-products of using a skill (bytecode from running its scripts, installs, OS
@@ -439,8 +481,8 @@ export function diffSkills(
// ── Removed-upstream (orphaned) skills ────────────────────────────────────────
//
// `skills add` / `init` / `hyperframes skills update` only ever add or refresh —
// none of them delete a skill that was renamed or dropped upstream (e.g.
// graphic-overlays → talking-head-recut), so a stale bundle lingers forever and
// none of them delete a skill the manifest no longer publishes (e.g. the
// eight retired workflow skills), so a stale bundle lingers forever and
// the manifest-only diff above can't see it. We surface these by cross-checking
// the vercel-labs/skills lock: a skill the lock attributes to OUR manifest
// `source` that the manifest no longer lists is "removed". Attribution is the
@@ -452,7 +494,7 @@ interface LockEntry {
sourceUrl?: string;
/**
* Path of the skill's SKILL.md within the source repo, as upstream records it
* (`skills/general-video/SKILL.md`, `.agents/skills/seam-craft/SKILL.md`). The
* (`skills/slideshow/SKILL.md`, `.agents/skills/seam-craft/SKILL.md`). The
* only field distinguishing skills the published manifest covers from ones
* installed out of the same repo's other skill roots — see manifestCoversSkill.
*/
@@ -550,7 +592,7 @@ const MANIFEST_COVERAGE_ROOT = "skills/";
*
* Only for skills installed from the directory the manifest is generated from.
* The lock records where in the repo each skill came from (`skillPath`, e.g.
* `skills/general-video/SKILL.md` vs `.agents/skills/seam-craft/SKILL.md`), and
* `skills/slideshow/SKILL.md` vs `.agents/skills/seam-craft/SKILL.md`), and
* both carry the same `source`, so source attribution alone cannot tell them
* apart. An entry with no `skillPath` (older lock format) is treated as NOT
* covered — for a delete, unknown provenance must fail safe. GH #3111.
@@ -578,8 +620,17 @@ function detectRemoved(
opts: { cwd?: string; home?: string },
): RemovedResult {
const lock = readSkillLock(lockPathForScope(root.scope, opts));
const removed = skillsAttributedToSource(lock, latest.source)
.filter((name) => manifestCoversSkill(lock?.skills?.[name]))
const attributed = skillsAttributedToSource(lock, latest.source);
const dropped = attributed.filter((name) => manifestCoversSkill(lock?.skills?.[name]));
// A retired name the lock attributes to us needs no skillPath (older locks
// lack one); a retired domain skill needs no lock entry at all when its
// bundle is on disk, because its prefix makes it ours by name.
const retired = RETIRED_SKILLS.filter(
(name) =>
attributed.includes(name) ||
(RETIRED_DOMAIN_SKILLS.includes(name) && existsSync(join(root.dir, name, "SKILL.md"))),
);
const removed = [...new Set([...dropped, ...retired])]
.filter((name) => !(name in latest.skills))
.sort()
.map((name) => ({ name, status: "removed" as const }));
+3 -3
View File
@@ -126,14 +126,14 @@ describe("mirrorGlobalSkills", () => {
it("mirrors the store into installed agents as relative symlinks (Unix)", () => {
const home = makeHome();
seedStore(home, ["hyperframes", "hyperframes-core"]);
seedStore(home, ["hyperframes", "hyperframes-cli"]);
installMarker(home, ".cursor"); // cursor present
installMarker(home, ".bob"); // IBM Bob present
installMarker(home, ".config/goose"); // goose present (XDG base)
// windsurf NOT installed (no ~/.codeium/windsurf)
const { mirrored, skipped } = mirrorGlobalSkills({
skills: ["hyperframes", "hyperframes-core"],
skills: ["hyperframes", "hyperframes-cli"],
home,
platform: "linux",
env: ENV,
@@ -159,7 +159,7 @@ describe("mirrorGlobalSkills", () => {
// goose lands in the XDG config dir (~/.config/goose), not ~/.goose
expect(
existsSync(join(home, ".config", "goose", "skills", "hyperframes-core", "SKILL.md")),
existsSync(join(home, ".config", "goose", "skills", "hyperframes-cli", "SKILL.md")),
).toBe(true);
});
+2 -2
View File
@@ -302,8 +302,8 @@ function isVideoFile(filePath: string): boolean {
* Unique path for the temporary 16kHz mono WAV fed to whisper.
*
* MUST be unique per call AND per process: callers run many `transcribe`
* invocations in parallel (e.g. the product-launch-video audio pipeline spawns
* one `hyperframes transcribe` per scene at once). A `Date.now()`-based name
* invocations in parallel (e.g. an audio pipeline that spawns one
* `hyperframes transcribe` per scene at once). A `Date.now()`-based name
* collides when two conversions land in the same millisecond — they clobber
* each other's WAV in the shared tmpdir, so whisper transcribes the wrong
* scene's audio and every colliding scene gets identical word timings.
+3 -3
View File
@@ -16,10 +16,10 @@
* fills the rest from the effect's own defaults. That keeps each entry readable
* as an intent rather than a dump of every knob.
*
* Node ORDER is load-bearing — the chain is serial, so a limiter first and a
* Node ORDER matters — the chain is serial, so a limiter first and a
* limiter last are different sounds. Every preset below runs
* subtractive filtering → dynamics → tone → character → limiter, the order
* `skills/hyperframes-audio` already teaches.
* subtractive filtering → dynamics → tone → character → limiter, as
* `skills/media-use/references/mixing.md` teaches.
*/
import {
@@ -1,5 +1,5 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initSandboxRuntimeModular, installAuthoredMediaCapture } from "./init";
import type { RuntimeTimelineLike } from "./types";
@@ -25,12 +25,8 @@ vi.mock("./colorGrading", async (importOriginal) => {
};
});
// The library itself, the repo's vendored 3.15.0, for what a stand-in would only assume.
const vendoredGsap = () =>
join(
dirname(expect.getState().testPath!),
"../../../../skills/music-to-video/references/motion-primitives/assets/gsap.min.js",
);
// The library itself, core's own gsap dependency, for what a stand-in would only assume.
const vendoredGsap = () => createRequire(import.meta.url).resolve("gsap/dist/gsap.min.js");
type RealGsap = {
ticker: { sleep: () => void };
getProperty: (target: Element, property: string) => unknown;

Some files were not shown because too many files have changed in this diff Show More