* fix(registry): make motion-blur match After Effects shutter-based blur
The motion-blur catalog snippet estimated speed from the x/y delta
between consecutive timeline updates and drew three one-sided ghost
copies with decaying alpha plus a Gaussian on top. After Effects does
temporal supersampling: each frame integrates the layer over a shutter
window (shutter angle, shutter phase, samples per frame), producing a
symmetric box smear of length speed x shutter time.
The snippet now does the same inside the page. After every timeline
update a deferred pass seeks the GSAP timeline to N sub-frame times
across the shutter window (events suppressed), reads each target's x/y,
restores the frame time, and drives an SVG filter that averages N
feOffset copies with equal weight (feComposite arithmetic, 1/N, sRGB).
Defaults follow AE: shutterAngle 180, shutterPhase -90, samplesPerFrame
16; fps comes from the render config, then the root data-fps, then 30.
Displacements are mapped through the inverse of the element's own
scale/rotation; percent x/y values are resolved to px; SVG targets use
their bbox; an existing inline filter is kept and the blur appended to
it. The pass runs in a microtask rather than inside the tracker tween's
onUpdate, because seeking GSAP from within its own render reads stale
tween state and suppresses the composition's callbacks on forward
seeks. The heuristic options blurScale/blurMax/stretchScale/stretchMax
are removed.
Reference: an AE-algorithm reconstruction of the same composition
rendered without the component at an effective 960 fps and averaged
over the 16 sub-frames of the 180 degree window per output frame.
Against it (1920x1080, 30 fps, solid + text translating linear then
power2.inOut): old mean PSNR 53.2 dB (min 26.7), SSIM 0.9930; new mean
PSNR 72.4 dB (min 52.6), SSIM 0.9998. The 10-90 edge ramp at 66.7
px/frame is 26.8 px in the reference, 27.5 px new, 14.5/6.3 px
(one-sided) old; at the ease peak 79.3 vs 79.5 vs 7.9/17.7 px, with
smear centres matching to 0.1 px.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(registry): drop render-config fps probe from motion-blur and refresh its docs
The snippet resolved fps by reconstructing the renderer's private seek
config key so the compiler's inline-script guard would not strip it. A
public catalog snippet must not depend on that key, so fps is now the
explicit option, then the root data-fps, then 30; renders with an fps
override pass fps explicitly. The catalog page's Usage, How it works and
Options sections still described the old velocity heuristic and are
rewritten for the shutter model. The 1/N weight keeps full precision.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(registry): match the After Effects reference export's shutter window
The shutter model landed on this branch was built against a reconstruction
of After Effects' algorithm, not against After Effects. A real 1920x1080 /
30 fps export of translating text disagrees with it on three points, all
measurable off the export itself.
The window is two frames wide, not half a frame. On the measured frame the
outermost duplicate on the trailing side sits 128 px back while the previous
frame is 125.8 px back, and the outermost leading duplicate sits 189 px ahead
while the next frame is 184.9 px ahead: the window ends land on the
neighbouring frames. Defaults become shutterAngle 720, shutterPhase -360.
Duplicates sit on the sub-interval boundaries, both ends included, not at
sub-interval centres. The staircase across a stroke steps every 15.6 px on a
125.8 px displacement and every 23.0 px on a 184.9 px one, i.e. an eighth of
a frame, with the run closing exactly on the neighbouring frame's position.
samplesPerFrame now means sub-intervals of the window, so there are N+1
copies at k/N rather than N copies at (k+0.5)/N.
The frame-time instance is drawn sharp over the smear. At full ink the core
bounding box is the whole word at every frame and total ink rises to 1.44x
the unblurred frame's, neither of which an average can produce. A final
feComposite over of SourceGraphic reproduces it. Each duplicate stays at
1/N: the staircase plateaus step by 0.063 +- 0.002 of the sharp text
intensity, flat, with no taper toward the window edges, and triangle
weighting scores 1.6 dB worse against the export.
Verified by replaying the export's translating section as a composition
whose sprite is the export's own rest-frame ink and whose eight legs follow
its own measured per-frame positions, rendered at 1920x1080 / 30 fps and
compared frame by frame over the text band. Mean PSNR against the export
rises from 24.19 dB (min 17.07) to 32.51 dB (min 27.57) across 72 frames,
and from 19.83 dB (min 17.07) to 32.03 dB (min 27.57) across the 26 frames
that actually move. Ghost extents match to a few px and the rendered
staircase plateaus match the export's to two decimal places. The export's
later scale, skew, flip and 3D rotation sections are out of reach here:
integrating anything but translation inside the shutter is engine work
tracked in issue #4010.
The filter region now clamps its bounds through zero. It was derived from
the duplicate offsets alone, which was correct while the output was nothing
but duplicates; the sharp instance draws at offset zero, so a window sitting
entirely on one side of the frame time - any shutterPhase above 0 or below
-shutterAngle - produced a region starting inside the element's own box and
cut the instance off. The defaults straddle the frame time and were never
affected, so the measured numbers above are unchanged.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
- Lint no longer reports root_dimensions_mismatch for sub-compositions, the
false positive v0.8.43 shipped with; a top-level mismatch is still reported
and a viewport-meta-only mismatch gets its own wording (#4012)
- The streaming encoder accepts lockGopForChunkConcat and gopSize, emitting the
same arguments as the disk encoder. Off by default, so an existing render
produces an unchanged FFmpeg arg list (#3973)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
- Portrait compositions render the full frame when the project was
scaffolded at a different size: html/body are sized to the composition
root at runtime; new lint warning root_dimensions_mismatch (#4005,
fixes#4001)
- render_complete telemetry adds composition_element_tags,
aroll_video_count, heygen_video_count; render_error adds error_name and
failed_stage_code (#4004)
- Catalog docs "Source" accordion titles are plain strings, so docs
search no longer shows [object Object] (#4006)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
The generated catalog pages rendered the "Source" accordion title as a
JSX expression, `<Accordion title={`name.html`}>`. Mintlify's search
indexer stringifies expression attribute nodes rather than evaluating
them, so every one of the 399 catalog pages surfaced its Source section
in search results as "[object Object]" (visible on the Scroll Feed page,
among others).
The template literal carried no escaping benefit: every title is a
single-segment `name.html` path, and a double-quoted JSX string handles
it directly. Introduce `mdxStringAttribute`, which emits a plain
`name="value"` attribute and escapes `& " < { }` as HTML character
references (MDX decodes references inside quoted attribute values, and
unlike JSON.stringify it does not rely on backslash escapes, which JSX
strings do not honour). Use it for both the Source accordion title and
the preview iframe title, and regenerate the catalog pages.
Regenerated with `npx tsx scripts/generate-catalog-pages.ts`; the only
change across the 399 pages is the one Accordion title line each.
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* chore: release v0.8.42
- Studio preview works for projects whose path has non-Latin-1 characters (#3982)
- Studio render downloads use RFC 6266 headers for non-Latin-1 filenames (#3984)
- check --json reports HDR auto-promotion; render prints one warning (#3985)
- Forced --sdr renders tone-map HDR sources before extraction (#3981)
- Render/preview workers no longer survive as orphans after Ctrl-C or parent exit;
cancellation reaches final artifact promotion; HYPERFRAMES_RENDER_DETACHED=1 for
intentionally detached renders (#3974)
- HDR preflight test coverage for held tails (#3963)
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* chore: release v0.8.42 notes — name the #3982 symptom and scope
- #3982 entry: a Studio preview server crash for compositions whose file path
has non-Latin-1 characters (the project directory is not involved)
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* chore: release v0.8.42 notes — name the #3982 symptom
- #3982 entry: the uncaught ETag TypeError took the preview server down, so
call it a Studio preview server crash rather than a preview failure
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* docs(changelog): weekly digest 2026-09-07–2026-09-14
Rewrites the generated draft into themed sections. 113 new commits across v0.8.32 through v0.8.40.
Signed-off by Rames.
* docs(weekly-updates): embed Sep 07-14 changelog video
Adds the weekly changelog video to the Week of September 7 digest entry,
matching the DocsVideo embed convention used by the prior weeks.
Video: 57.0s, square 1080, h264 + aac. Verified through CloudFront -
content-length and SHA-256 both match the local render exactly.
- Rames Jusso
* feat(cli): render summary names the capture path, gpu mode and stage timings
* fix(lint): drop gsap_exit_missing_hard_kill, its hazard does not reproduce on cold workers
* fix(cli): render slow-path hint fires only when the gpu was auto-probed
* test(cli): isolate agent hint suite from ambient agent env keys
The suite runs inside agent sessions. Ambient env keys fill the 16-key
hint cap and fail assertions that a plain shell would pass.
* docs(music-to-video): drop leftover hard-kill mandate after lint rule removal
Frame workers still required a tl.set hard-kill that the dropped lint
rule no longer enforces. Align the self-check with montage.md.
* refactor(cli): share hint-key pattern and collapse render summary args
Export HINT_KEY_PATTERN for the test isolation strip. Read duration and
frame count from the perf summary instead of passing them beside it.
* test(cli): lock capture-mode preference and 16-key hint isolation
Session mode, including drawelement, must print over observability.
The hint suite now fails if ambient keys already fill the 16-key cap.
* feat(cli): default BeginFrame, agent blank, and looks quality
Local auto render opts out of the software-GPU screenshot clamp so
BeginFrame can run. init --agent scaffolds a centered Inter blank with
no prompts. --quality looks (now the default) is CRF 16; delivery is high.
* fix(cli): honor screenshot env and teach agent pitfalls on the happy path
Local auto still requests BeginFrame unless PRODUCER_FORCE_SCREENSHOT=true.
The agent blank fills its canvas so --resolution can resize it. Skills now
name the four pitfalls and tell agents to read the render summary line.
* feat(cli): default init to the centered blank
Bare init scaffolds the centered Inter stage. --agent is a hidden alias.
* fix(cli): pack from-file and use it for video init
TTY --video with no --example selects from-file. Pack copies that template.
* fix(cli): wire --video onto from-file even with --example blank
One resolver picks the scaffold. Spawned init --video asserts a-roll src.
* fix(cli): narrow looks CRF on the quality alias
Only looks carries crf. Typecheck failed on the union without a guard.
* style(cli): shorten render-pipeline summary comment
The install command is the one line a reader comes to a catalog page to copy,
and it sat below the whole variables explorer, under its Customize panel, a
screen or more down on long items. Move the Install section above the preview
and regenerate all 399 pages.
## What
`keyframes --shot --layout strip`'s help text, its type's inline doc comment, and the CLI reference docs all described `strip` as an unqualified "filmstrip by time." The tool doesn't actually do that for the overwhelmingly common case.
## Why
A real per-time pixel filmstrip is only produced when the sampled selector is an SVG element (gated by an internal shape check — `typeof element.getBBox === "function" && typeof element.getScreenCTM === "function"`). Any other selector — including every nested sub-composition host, which is always a `<div data-composition-src>` — silently falls back to one live screenshot plus vector position markers instead.
This isn't a capture bug: for a non-SVG selector, real per-time pixel compositing was never implemented, only 3D bbox/marker sampling. But the documented behavior over-promised what the tool does, so a user following the docs on the common case (a DOM/sub-composition selector) sees root captions and empty image boxes where they expected the nested composition's actual content to move across frames — the diagnostic strip is misleading, even though the real render is correct.
## How
Reworded all three descriptions (CLI help text, `ShotOptions.layout` TSDoc, and the reference docs table) to state the SVG-only condition and the DOM/sub-composition fallback explicitly. No behavior changed — this is a documentation-accuracy fix, per the ticket's own framing that a doc-only fix fully resolves the reported symptom (a silent, misleading omission) for a P3.
## Testing
Added a test asserting the CLI help text no longer makes the unqualified "filmstrip by time" claim and does disclose the SVG-only condition — guards against a future regression back to the misleading wording. Verified RED (fails against the pre-fix string) and GREEN (passes after the fix) via a local before/after comparison.
- `bunx vitest run src/commands/keyframes.test.ts src/commands/motionShotLayout.test.ts` — 46/46 passing
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean
- Full CLI suite (excluding known-broken-in-sandbox browser-launch tests, unrelated to this change): 217 test files / 3025 tests passing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* fix(motion-graphics): make the catalog search fire before hand-authoring
The workflow's only reuse instruction pointed at catalog-map.md, a
hand-maintained snapshot of ~60 registry items, and no file in the skill
ever named `hyperframes catalog --query`. An agent asked mid-build for
CRT scanlines and a glitch effect had no instruction to search, so it
hand-authored both while caption-glitch-rgb ("RGB chromatic aberration
with CRT scanline overlay") ranks first for that query on either tier.
The search reads the hosted registry and needs nothing installed, from
any directory with no project, so "the components were not installed"
was never the cause. Say that where the reader is, since the wrong
diagnosis is the intuitive one.
Director Part 2 and the Builder now run the search before naming a
block, and catalog-map.md is labelled a partial snapshot whose misses
prove nothing. Pinned by a content test in coreSkillContent.test.ts.
* fix(skills): search the component catalog before hand-building a look
Authoring workflows never told the agent to search the component library,
so agents rebuilt effects the registry already shipped. A user reported
building an effect from scratch that the registry already contained; the
search that would have found it needs nothing installed, which is why the
usual self-diagnosis ("I forgot to install the components") is wrong.
All ten workflow skills carried zero mentions of `hyperframes catalog`.
The instruction lived only in hyperframes-cli and hyperframes-registry,
both loaded on demand, and the registry skill's own trigger named the
command rather than the symptom - circular, because an agent that never
thought to search could not reach the doc telling it to search.
- Eight workflows now run the search at the point they decide what to
build, before authoring. The two that compile through a closed
authoring vocabulary (embedded-captions, talking-head-recut) document
why they deliberately do not.
- hyperframes-registry triggers on the symptom (a named look, effect,
treatment or transition) instead of the command name; the router table
and the catalog surfaces carry the same framing.
- Fixes hand-maintained lists that had drifted: bar-chart-race was listed
as a hand-author gap in two files while shipping in the registry;
stat-motion was named as an installable block and is not one; the
caption-* family count was one high; the registry discovery tables
claimed to be the block list while covering 97 of 180.
- bun run lint:skills now fails when a doc marked as a registry snapshot
names an item the registry does not have.
* refactor(scripts): reuse native recursive readdir and the shared registry type
Simplify pass on the new registry-snapshot check, behaviour identical:
- collectMarkdownFiles uses readdirSync({ recursive: true }) instead of
hand-rolled recursion, matching scripts/generate-template-previews.ts.
- registryItemNames types registry.json with the exported RegistryManifest
instead of an ad hoc inline shape, matching scripts/catalog/build-local-vectors.ts.
The runtime guard stays: a cast describes the file, it does not validate it.
- One report() helper replaces the duplicated print-and-count block in both
lint passes.
* refactor(scripts): name the registry check's blind spots and stop self-arming
Applies the review findings on the new check, behaviour identical except
where noted:
- lintRegistryItemRefs returns null for an unmarked file instead of an
empty array, so "not a snapshot" and "a clean snapshot" have one owner
and the marker is matched once rather than twice.
- Marker detection ignores fenced blocks, so a doc that documents the
marker syntax in an example no longer arms the check on itself. The id
scan still reads full content, so fenced examples stay covered.
- The header comment and two tests now pin both known false negatives:
identifiers outside backticks, and single-word item names. Measured on
the six marked files, dropping the hyphen requirement would monitor 3
more items and force 46 allow= entries for ordinary prose words, so the
requirement stays and the gap is stated instead of silent.
* chore(skills): regenerate skills manifest after catalog-search edits
* feat(registry): add 25 image carousel blocks (5 families × 5 variants)
Five carousel families, each with 5 style variants:
- Orbit (1–5): image cards on a spinning 3D Fibonacci sphere
- Path (1–5): cards following animated CSS motion paths
- Circle (1–5): circular carousel layouts
- Vision (1–5): Apple Vision-style spatial presentations
- Text Circle (1–5): circular carousels with text overlays
All blocks are 1920×1080 at 6s, with 12–24 configurable image slots.
Includes catalog preview thumbnails for each block.
Co-Authored-By: Jake Moran <jake.moran@heygen.com>
* refactor(registry): host carousel block images on the CDN
The registry is served straight out of this repository
(DEFAULT_REGISTRY_URL points at raw.githubusercontent.com), so every byte a
block ships is permanent history. The 25 carousel blocks added 421 JPEGs,
27.7 MiB in a checkout, and made the diff 472 files. Only 23 of those images
were distinct: the same 12-24 placeholders were copied into every block.
files[] entries gain an optional `url`. When set, the installer fetches the
bytes from there instead of joining the registry base. `path` does not change
and still says where the file lands relative to the item, so composition HTML,
target mirroring and `hyperframes add` behave exactly as before.
Keys are content-addressed, so the 396 manifest entries resolve to 23 objects,
and a changed image gets a new URL rather than a stale one cached behind
`immutable, max-age=31536000`.
The catalog preview renderer copies an item's directory and renders it, so it
needs the same materialisation step. Without it the preview draws every card
blank and reports success, which is worse than failing.
Also drops registry/catalog/, 25 hand-made thumbnails referenced by nothing;
catalog previews are rendered by CI and served from docs/images/catalog.
Verified: all 23 objects return 200 from the CDN with hashes matching their
keys; `hyperframes add carousel-orbit-1` against a local registry installs 24
real JPEGs; the preview render produces the album art, and produces blank
cards when the fetch step is removed.
* style(registry): format the carousel composition HTML
`oxfmt --check .` covers the whole tree, and these 25 files were never run
through it. The pre-commit hook only formats staged files, so nothing local
caught it.
* feat(catalog): publish the carousel blocks without republishing their images
These 25 blocks had no Catalog page. Every other item in the registry has one,
so they shipped invisible: installable by name, unfindable by browsing.
Generating them naively undid the change they were added by. The Catalog
payload copies an item's assets into docs/public/, which is tracked, so the 396
images this PR just removed came back as 43 MB one directory over — worse than
the 3 MB they started as, because each block got its own copy.
The copy exists because these compositions assemble `img.src` at run time out
of a variable value, so there is no `src="..."` in the markup for the payload's
asset scan to resolve. An unpredictable path can only be satisfied by serving
every file beside it, which is what `needsOwnDirectory` asks for.
An absolute URL needs no directory: the scan already skips any `https:`
reference. So for the payload path only, hosted files are left undownloaded and
the composition's variable defaults are rewritten to their URLs. The preview
renderer still downloads them, because it paints real frames and a missing file
is a blank card.
The explorer posts every value to the preview frame on mount, including
untouched ones, so the page's variable list carries the URLs too. Left as local
paths they would have overridden the payload's own defaults and asked the frame
for a file that was deliberately never published.
Result: 25 pages, 25 payloads, zero bytes of image added.
Verified: a spike item declaring no assets at all rendered its 24 covers from
the CDN, proving the variable-default path; payload generation for a carousel
block now writes no item directory and no shared asset; the preview render
still produces the album art. mint validate and mint broken-links pass on the
new pages. test:scripts is green.
* refactor(catalog): split the hosted-asset step out of prepareProjectDir
Two functions rather than one: finding the composition and rewriting its
variable defaults are separate jobs, and inlining the mode branch pushed
prepareProjectDir past the complexity gate it was already sitting on.
Behaviour is unchanged. Re-verified both paths after the split: the payload for
a carousel block still writes no item directory and no shared asset, and the
preview render still produces the album art.
* feat(catalog): give the carousels their own shelf
25 image carousels landed in Showcases and were 53% of it, so the scenes that
shelf exists for disappeared underneath them. That is the same shape the 24
editor themes made, and it gets the same fix they got.
Keyed on the first tag, which is this file's stated grouping rule, rather than
on the name. `screen-flow-carousel` leads with `product-demo` and stays on the
shelf that says what it is for; a future carousel that is not named
`carousel-*` still lands here.
Showcases 47 -> 22, Carousels 25, and no existing item changed shelf.
* fix(registry): centre the circle-5 carousel path in its composition
Its ring was centred at x=3832.6 in a 3840-wide composition, so it sat on the
right edge and most of it fell outside the frame. Only a few cards were ever
visible, cropped, with two thirds of the composition empty.
The exported path carried absolute coordinates from a layout that was never
recentred. Shifting the four vertices by (-1912.597, -4.340) puts the ring on
the composition centre. Handles are relative, so only the anchor points move
and the shape is unchanged.
carousel-text-circle-5 shares the identical path and had the identical fault.
The other three circle variants sit within 12% of centre, which reads as
authored placement rather than the same bug, so they are left alone.
* fix(catalog): rebuild the circle-5 payloads after recentring the path
The Catalog preview plays the payload, not the composition on disk, so
recentring the source changed nothing a reader sees. The payload still carried
the old vertices and the ring still hung off the right edge of the frame.
Verified the consumer this time, not just the producer: both payloads now
resolve to a path centre of x=1920. The other 23 rebuild byte-identical, so the
formatting pass did not reach them.
* feat(catalog): promote Carousels to its own section
It was a shelf inside Scenes & demos, which is where a scene type belongs by
kind but not by weight. At 25 items it is larger than Data & charts (17) and
Blocks (13), each of which is already a section holding a single shelf, so the
catalog's own precedent puts it one level up.
Pulling it out also takes the largest section in the catalog from 120 items to
95, which is the reason the shelf was added in the first place.
The two circle-5 pages change because their embedded source block carries the
recentred path; nothing else in them moved.
---------
Co-authored-by: Jake Moran <jake.moran@heygen.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* docs(changelog): weekly digest 2026-08-31–2026-09-07
Rewritten from the 111 non-merge commits in the window, grouped by theme.
The week was mostly hardening: file reads pinned to checked descriptors,
bounded parser scans, and isolated concurrent writes. Studio agent control
and site capture continued.
Every commit sha and PR number was resolved and verified against the repo,
not hand-typed.
— Rames
* docs(changelog): add weekly changelog video for 2026-08-31–2026-09-07
Embeds the rendered 49.5s digest video. Uploaded to
static.heygen.ai/hyperframes/changelog-videos/weekly-changelog-aug31-sep07.mp4
and verified live before committing the src (200, content-length matches the
object, cache miss).
— Rames Jusso
* Add Mini Course Generator to adopters list
Added a new card for the Mini Course Generator showcasing its AI-powered course generation capabilities.
* docs(adopters): add Mini Course Generator
---------
Co-authored-by: eren-commits <eren@minicoursegenerator.com>
Bare `hyperframes skills` passed `--skill '*'` to the upstream installer, which discovers every SKILL.md in the clone, so a full install put 26 skills on disk against the 20 the manifest publishes (the six repo-internal ones under .claude/skills / .agents/skills). It now resolves the canonical manifest and installs its names, the same path `skills update` uses; offline it warns and degrades to the pinned core set instead of the wildcard. Docs point the full set at `npx hyperframes skills` and say what `skills add --all` really pulls.
Rebuilding footage means checking the render against the source at the same timestamps. `--against <video>` saves the reference frame at every captured time (frame-exact seek: -ss after -i) plus a labelled render|reference pair sheet built with the existing contact-sheet helper. Reference frames and pair sheets are derived artifacts, kept out of the snapshot list like contact-sheet.jpg. No new dependencies.
Capture drops assets for four reasons and reported none of them, so a folder
with thirty images and a folder truncated to thirty images were the same
object. Every drop site was a bare `continue`, `break`, `return null` or an
empty `catch`, and the only signals downstream were two hand-written warning
strings that fired when the budget was already gone before a download pass
started, which is the one case where the pass could not say how much it lost.
`downloadAssets` and `downloadAndRewriteFonts` now return an `AssetDropCounts`
tally beside their result, incremented at the single line that performs each
drop. `capture --json` carries it as `dropped`; the human summary prints a
`Dropped:` line when it is non-zero.
Four reasons, three decisions and one failure:
size-floor fetched, then judged too small to be a real asset
budget-exhausted the post-navigation clock ran out before this one
cap-reached 30 inline SVGs, 30 fonts, or 6 faces per family
unavailable the request or the write failed
A break now counts everything it did not reach rather than the one it stopped
on, because "how many did we lose" is the question and one is never the answer.
The two budget warnings are gone. Both existed only to cover the case where
the budget ran out before a pass was called, so both passes are now called
unconditionally: a zero budget makes each loop break on its first item and
record `budget-exhausted` for the rest, which costs no network and produces a
real number instead of the word "some". The single remaining warning is derived
from the tally, so the prose and the count cannot disagree.
Measured on a live capture of a large marketing site:
default budget 232 kept, 91 dropped (39 size-floor, 20 cap-reached,
32 unavailable)
15s budget 30 kept, 299 dropped (279 budget-exhausted, 20 cap-reached)
Same page, same command, and until now both runs described themselves the same
way.
* feat(registry): add kinetic center build component
* chore: regenerate search vectors for kinetic-center-build
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(registry): load all kinetic center build font weights
* chore(catalog): regenerate local search vectors
---------
Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* feat(registry): say what a promoted template's slots mean, not where they sit
Every declaration described its slot by position — appName was "small credit in the corner
of every banner" — and every editing contract described the supplied brand only by what it
may not touch, chatgpt-exchange going as far as "only the subject discussed inside the
conversation". An agent filling those slots has nothing telling it that the video is an
advertisement for that brand, and remixes shipped with an invented brand instead of the
real one.
Adds `portrays` to the 35 identity-bearing variables across seven templates: what the slot
stands for, as a list, kept orthogonal to `role` (which says which aspect of the
composition a knob affects, not what a value means to a viewer). Rewrites those variables'
descriptions to state meaning, and gives every TEMPLATE.md the affirmative sentence it was
missing. notes-reveal now records honestly that it declares no identity slot at all, which
is why a remix of it cannot carry a brand.
`portrays` is optional metadata: rendering ignores it, the promoted contract still projects
{id, type, role}, and packages that predate it keep working. Variable ids, types, roles,
labels, and defaults are untouched, so installs and renders see the same contract.
* feat(notes-reveal): give the template somewhere to say whose ad it is
This was the one promoted template that declared no identity slot at all, and the
previous commit could only record that honestly. A remix of it could not name the
advertiser, could not carry its domain, and had nowhere to place its mark — so the
ad it produced was unattributable no matter how good the copy was.
The closing card ends at y=1708, leaving 212px of dotted scene. A sign-off strip
lives there: `brandLogo` (portrays subject_logo) above `brandDomain` (portrays
subject_domain), in the card's own plum and Courier Prime. Putting it outside the
card means the hand-lettered headline and the checklist keep the geometry they were
designed with — verified by `hyperframes check`, which reports the same 0 errors and
the same warning counts as origin/main, with one more contrast check (367/367 AA).
No name slot: this template has no text slot that can hold a bare brand name.
`cardTop`'s marker line continues into the fixed words "OF ONE FILE", and a name set
beside a wordmark reads twice — rendering it proved that before this was committed.
The mark plus the domain carry the attribution, which is what the other end-card
templates do.
Rendered to check rather than assumed: the packaged default reads as the HyperFrames
wordmark over hyperframes.heygen.com, and a resolved symbol mark (Notion's cube over
notion.com) sits correctly in the same strip.
* fix(notes-reveal): make the note the ad, and stop the headline leaving the card
Three defects, all visible in the reported gamma remix.
The seven body lines carried their characters as pre-split `<span class="ch">`
elements so the reveal animation could address them by id. That made the note
permanently unwritable: 46% of the 24.9s runtime, and the only place the note
says anything, was fixed copy about making videos with HyperFrames -- shipped
inside an advertisement for a Dutch DIY chain. The spans are now built at
runtime from seven declared variables, keeping the same ids, so the animation
is untouched and the copy is the brand's.
`cardMid` and `cardBottom` ("OF", "ONE FILE") were literals with no variable at
all, so a remix could only ever change the first of the card's three lines.
Every text slot sat at a fixed left edge on one line, guarded only by a
character cap -- the wrong unit. `cardTop` allowed 12 characters; "THE POWER"
(9) fits and "CREATE DECKS" (12) does not, which is why both reported gamma
remixes shipped with the closing headline over the edge of the card. Slots are
now fitted to their box at render time, shrinking type only as far as needed and
never past the designed size. The card headlines also carried
`data-layout-allow-overflow`, switching off the one check that would have caught
it; those waivers are gone. The note's title and body keep theirs, because
`#note-body` is translated across the whole scene and leaves the canvas by
design.
Timing is preserved rather than regenerated: each body line is revealed inside
the frame window the packaged rhythm already used, so `JUMPS` and the hand-tuned
`SCROLLS` curves stay valid and the composition is still 24.867s for any length
of copy. A line whose length matches the packaged copy keeps its rhythm frame
for frame.
Verified: rendered with the packaged defaults, SSIM 1.000000 across all 746
frames against the previous template -- the design is bit-identical. Rendered
with the gamma values that overflowed, and with a stress set of long and
one-word values: both stay inside the card and the note, both still 24.867s.
`hyperframes check` passes, with layout warnings down from 28 to 8 and no new
issue class. Note that `check` takes no variable values, so it guards the
template's defaults and the fit is what guards a remix.
* fix(notes-reveal): make the fit floor and the character caps agree
A cap that admits a string still too wide at the floor font size puts the text
back outside its box -- the exact bug the fit was added for. With the floor at
0.6 and the title cap at 40, a 40-character title still overflowed `#note-body`
by 30px, because "Things nobody told me" is 21 characters and 900px at 88px, so
42.9px per character, and 40 characters need to reach about 0.45 of the design
size to fit.
Floor to 0.45 and the caps in: titles 34, body lines 52, cardTop and cardBottom
16. Every cap is now satisfiable with room to spare, and the common case stays
far from the floor.
Found by probing all eight promoted templates with values about 1.8x the length
of their packaged defaults, which is the length a real remix produces; short
sentinel values only ever prove a slot is wired.
Defaults are unaffected: SSIM 1.000000 across all 746 frames, still 24.867s,
`hyperframes check` still passes.
* fix(exchange): scroll the composer to follow the caret
`prompt` is an editable slot in both templates, but `.comp-text` is a
fixed-width single line with `white-space: pre` inside a composer that clips,
and the packaged prompt already reaches the mic -- it has no headroom at all.
A longer prompt therefore slid under the mic and the send button and lost its
tail, with the caret hidden behind them: the remix read as having typed into
nowhere, cut mid-word.
Both now scroll the typed text left to keep the caret in view, the way a real
single-line input does, on the very per-character times the reveal already uses.
The limit is measured to the opaque round button rather than the mic, because the
packaged prompt's caret already sits 2px past the mic's left edge in
chatgpt-exchange -- measuring to the mic would scroll the packaged composition.
Shrink-to-fit was the other option and it is worse: with no headroom it shrinks
the packaged prompt too, and it still cannot absorb a prompt twice as long
without unreadable type. It was tried, measured, and dropped.
A prompt that fits emits no keyframes and the element is never handed to GSAP at
all -- even a zero translate stamps a transform, which changes how the text
rasterizes.
claude-exchange puts this inside its existing `build()`, which already re-runs on
`document.fonts.ready`, so it measures the final font. chatgpt-exchange builds
once, so its scroll keyframes are kept in a list and rebuilt on the same event.
Verified on the packaged defaults: claude-exchange SSIM 1.000000 across 1284
frames. chatgpt-exchange is not frame-deterministic -- rendering it twice
unchanged gives 0.999959 with 249 frames under 0.9999, and before-vs-after gives
0.999967 with the same 249 -- so the change sits inside its own noise. Durations
unchanged at 14.9s and 21.4s. `hyperframes check` reports byte-identical results
to the unchanged templates.
* fix(notes-reveal): fit the checklist cells instead of counting their characters
The three checklist rows were the only text slots with no width fit, so
their character cap was doing the job a measurement should: it was set to 8
because that was the widest the packaged strings needed, and a gamma.com
remix delivered "DIRECT MEE" and rendered "DIRECT M".
Fit the labels and values like every other text slot -- dividing the
label's scaleX(0.86) back out of its column, since fitWidth measures the
untransformed width -- and return the cap to a backstop at 18.
"DIRECT MEE" needs no shrink at all: it ends at x=694 against a tick at
x=715, so the truncation was never about room. Measured: the packaged
defaults still render at their authored 44px and 50px, and all three values
clear the tick.
* fix(chatgpt-exchange): nest the caret scroll so its rebuild can be undone
`tl.to()` returns the TIMELINE, not the tween it created, so `caretScrollTweens`
held 49 references to the master timeline. The fonts-ready rebuild then called
`.kill()` on the master timeline 49 times and re-added its keyframes on top of
the ones it meant to replace. Measured with an overflowing prompt:
master timeline detached from the global timeline (tl.parent -> null)
scroll tweens on #cge-typed: 49 before the rebuild, 98 after
49 timestamps left with two competing tweens on the same property
GSAP's `Animation.kill()` interrupts and detaches rather than killing children,
so the composition kept rendering under an explicit seek -- which is why the
packaged-defaults SSIM check passed and never saw this. It is still wrong: the
timeline is off the ticker, and the stale keyframes were measured against the
fallback font.
Only an overflowing prompt reaches it, i.e. exactly the remix the scroll exists
for. The packaged prompt emits no steps, so nothing is created and nothing is
killed.
Now one nested child timeline holds the steps: a real object that can be killed
and replaced, and killing it cannot reach `tl`. Children sit at the same absolute
times and the nest is added at 0, so the motion is unchanged.
Verified: overflowing prompt holds at 49 scroll tweens across the rebuild with no
duplicated timestamps and the timeline stays attached. Packaged defaults against
the pre-feature baseline -- SSIM 0.999998 over 894 frames, 13 frames below
0.9999 against a 249-frame noise floor for this template, duration 14.9s
unchanged.
Found by Magi in review of #3562.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(lint): catch a timeline's return value being treated as a tween
Magi asked for a regression that makes the caret-scroll failure unable to return.
A render fixture cannot be that guard, and neither can a unit test. Both were
measured before choosing this:
- The unit-test DOM has no layout. In happy-dom `getBoundingClientRect()` is
{left: 0, width: 0} and offsetWidth/offsetLeft are 0, so the template's own
guard computes limit = -8 and returns before collecting a single keyframe,
whatever the prompt length. `document.fonts` does not exist there either, so
the rebuild never runs. Both preconditions are unreachable -- the test would
pass on the broken code and the fixed code alike. Rames flagged this; verified.
- A render fixture would not fail either. Rendering the long prompt through the
real pipeline on the broken vs fixed template gives SSIM 0.999994 over 894
frames with ZERO frames below 0.999. The renderer drives the composition by
explicit seek, and the duplicated keyframes are added last so they win, which
is exactly why `.kill()` on the master timeline stayed invisible. A golden mp4
would have cost a fixture and a shard and guarded nothing.
What is testable is the root cause, and it is a static property: `tl.to()` returns
the TIMELINE, `gsap.to()` returns a Tween, and the two read identically. So the
guard is a lint rule, which needs no DOM, fires on the source, and covers every
composition in the registry rather than this one template.
The rule keys on the timeline handles `collectTimelineVarNames` already finds,
which is what keeps `gsap.to()` and `Array.from()` out of it. Pushing a timeline
return into an array is flagged outright -- that is the collect-to-discard shape.
A bound return is flagged only once something tween-scoped is aimed at it, so a
pointless-but-harmless capture stays quiet.
Verified against the real files, not just synthetic ones: the pre-fix
chatgpt-exchange raises exactly one error and names the offending line; the fixed
file is clean. Across all 642 HTML files under registry/ and examples/ the rule
finds nothing, and lint output on the eight promoted blocks is identical to
origin/main error-for-error and warning-for-warning, so error severity costs no
existing template. 540 lint tests pass, 6 of them new.
Requested by Magi in review of #3562; unit-test DOM measurement by Rames Jusso.