Commit Graph
4079 Commits
Author SHA1 Message Date
James RussoandLance Curtis d3a4e90370 fix(skills): stop embedded-captions shipping author-only paths; dedupe slideshow (#3225)
* fix(skills): stop embedded-captions shipping author-only paths

The skill is distributed via `hyperframes skills` (--copy, so the installed
bundle matches the published tree), but three shipped files pointed at
directories that only exist on the original author's machine.

- references/test-set.md: deleted. Nothing in the skill referenced it (zero
  inbound links across all 140 files), and its corpus lives at
  ~/Downloads/heygen_relevant_videos/, so it was neither reachable nor runnable.
- SKILL.md / dna/README.md: the retired 54-template archive was stated as
  living at a ~/Downloads path. Keeps the fact, drops the false location.
- themes/PORTING.md: marked maintainer-only. It authors new theme DNAs rather
  than using the skill, nothing links it, and its inputs (the cap_fx3 demos,
  the frame corpora, CONTRACT.md) are not distributed. The paths stay as the
  author's original layout, now explicitly labelled as names for the inputs.

The 10 references to ~/Downloads/hyperframes are untouched: those are the
documented last resort in a real chain (HYPERFRAMES_ROOT -> in-repo -> that
path), and every file carrying it also carries the env var.

Regenerates skills-manifest.json, which hashes the whole skill directory.

Closes #3219

— Rames Jusso (James's assistant)

* fix(skills): delete themes/PORTING.md instead of marking it internal

Nothing in the skill referenced it, its inputs are not distributed, and git
history keeps it recoverable. Marking a dead procedure still ships the dead
procedure.

— Rames Jusso (James's assistant)

* fix(skills): remove duplicated media-cleanup block in slideshow

The media-cleanup and global-nav-mute sections appeared twice in
skills/slideshow/SKILL.md. The two copies were not identical: the later one
was missing the paragraph requiring custom media controls to sync through
media events. Deletes the incomplete copy, keeps the complete one.

Originally authored in #3218. Carried here re-signed because the org requires
signed commits and both changes regenerate skills-manifest.json, which would
otherwise conflict between the two PRs.

Co-authored-by: Lance Curtis <69400491+imprimisxo@users.noreply.github.com>

* chore(skills): regenerate manifest for slideshow + embedded-captions

— Rames Jusso (James's assistant)

* chore(skills): record the two embedded-captions deletions in the guard

check-no-main-deletions.mjs deliberately has no blanket override, so each
intentional deletion is named with its reason and shows up in review.

— Rames Jusso (James's assistant)

---------

Co-authored-by: Lance Curtis <69400491+imprimisxo@users.noreply.github.com>
2026-08-11 14:57:32 -04:00
Xuanru LiandCursor 7860d19433 fix(capture): fall back from networkidle2 to domcontentloaded on nav timeout (#3224)
* fix(cli): fall back from networkidle2 to domcontentloaded on capture nav timeout

Sites like yahoo.com never reach network idle, so website capture hung for
the full 120s navigation budget. Prefer a short networkidle2 attempt, then
continue with domcontentloaded and the existing settle/scroll path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(capture): use remaining timeout budget for domcontentloaded fallback

Keep total navigation time within the caller --timeout instead of
re-applying a full 30s floor after networkidle2.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 11:31:59 -07:00
Miguel Ángel 896bc336a2 feat(studio): show every colour of a mixed selection in the swatch (#3144)
* feat(studio): show every colour of a mixed selection in the text swatch

Selecting text painted in more than one colour showed a white swatch. The
toolbar reads a property only when the whole selection agrees on it, which is
right for bold and italic (a toggle is on or off) but wrong for a swatch: with
nothing to report it fell back to the default, so a red-and-green selection
claimed to be white.

The swatch now reads the colours as they run through the selection and draws
one band per run, sized by how many characters carry it. Hard stops, not a
fade — it reports the colours that are there, and a blend would draw colours
that are not. A single-colour selection is a plain swatch, as before, and
picking a colour still applies it to everything selected.

* feat(studio): blend the mixed-colour text swatch instead of banding it

Bands read as two separate swatches sitting next to each other. Each colour
now sits at the middle of its share and the browser fills between them, so the
control looks like one swatch holding a mixed selection.

* fix(studio): keep whitespace out of the text colour swatch

Colouring a whole element and then recolouring one word inside it leaves the
spaces around that word carrying the first colour. The swatch counted them, so
a red word inside green text drew a sliver of green, then red, then green —
the element's colour appearing at an edge where no glyph is painted in it.

Whitespace paints nothing, so it no longer contributes a colour. The swatch
shows the colours the glyphs are actually drawn in, in the order they appear.

* fix(studio): stop the colour swatch repeating its gradient under the border

The swatch grew a green edge on its red side and a red edge on its green side.
`background` maps a gradient to the padding box and then repeats it to fill the
border box, so the 1px ring showed the strip either side of the tile: the
gradient's end colour along the leading edge, its start colour along the
trailing one, both read as a mirrored copy of the swatch.

Painting from the border box instead gives the ring the colour the glyphs next
to it are actually drawn in.

* fix(studio): drop the highlight when a text edit closes

Picking a word with a double press and then clicking away left the word
painted grey. The element was no longer being edited, but the text still read
as selected.

Ending the edit removed contenteditable and blurred the element, and neither
of those drops the browser's own selection. It now clears the selection as
part of the teardown, and only when the selection lives inside the element
being closed — one somewhere else in the preview belongs to whatever put it
there.

* feat(studio): match the mixed-colour swatch to the one in the design tool

The swatch drew a proportional blend along the horizontal: each colour took
the share of the sweep that its characters took of the selection. At 16px that
reads as one muddy smear, and a colour used by a single character is almost
invisible — the opposite of what the control is for, which is answering "which
colours are in here".

It now sweeps diagonally through each distinct colour, evenly spaced, the way
the mixed-colour swatch works in the design tool this sits alongside. A colour
appears once however much text carries it, and the dot itself matches that
reference too: 16px, a 2px ring, and a small lift on hover.

The character counts had no other consumer, so the reader hands back the
distinct colours in document order rather than counting.

* fix(studio): harden mixed-colour text swatches

* refactor(studio): split inline text style readers
2026-08-11 04:26:31 -04:00
Miguel Ángel 4cc46f5f9f feat(studio): edit and style text in the preview (#3143)
* feat(studio): edit and style text in the preview

Double-press a text element in the canvas and the caret opens where you
pressed, in the element itself rather than in a panel. Select characters and
a small toolbar offers colour, bold, italic and underline, applied to
exactly those characters.

The toolbar lives in Studio's document rather than the composition's.
Putting it in the preview would inject Studio's chrome into the user's
composition, where a render would capture it and the composition's own
styling would inherit into it.

In a flex or grid container the rebuilt runs go inside one wrapper, so a
coloured word cannot reflow the element it sits in.

Also fixes the keyboard: the shortcut guards matched contenteditable=true
only, so playback shortcuts ate letters typed into the composition.

* refactor(studio): keep domEditingLayers under the size cap

The rich-text operation pushed this file past the 600-line gate. Same change
the branch made later, landed with the commit that caused it.

* test(studio): wrap selection changes in act

* fix(studio): restore rich text after failed save

* fix(studio): polish inline text editing

* fix(studio): harden inline text editing
2026-08-11 03:54:18 -04:00
Miguel Ángel cb73c8dc2e feat(studio): apply a style to a run of characters (#3142)
* feat(studio): apply a style to a run of characters

Styling text in a composition cannot be done by wrapping a DOM range in a
span. That is three lines, and then every interesting case is a special
case: recolouring nests spans that shadow each other, removing a style
cannot reach the ancestor that set it, and styling across an existing run's
boundary has to split it. Each fix is a new branch and the branches
interact.

So the element is read into a flat list of styled runs, the style is applied
to a span of characters in that list, and the element is rebuilt from it.
Replacing, removing, splitting and merging stop being cases: the rebuild
emits one span per distinct run and cannot nest or duplicate, whatever was
there before.

Selection offsets count UTF-16 units, so a boundary can land between the
halves of an emoji; the applied range widens to whole characters. A colour
an ancestor overpaints is mirrored into the fill, because a colour that does
not paint reads to the user as a colour that did not save.

The toolbar that drives this arrives with the editor in the next change.

* fix(studio): harden inline text styling boundaries

* fix(studio): align inline styling with persistence

* test(studio): pin inline identity delimiters
2026-08-11 02:48:56 -04:00
Miguel Ángel 636dc042a7 feat(core): sanitize rich text on the way into a composition (#3141)
* feat(core): sanitize rich text on the way into a composition

Studio's patch vocabulary was inline-style, attribute, html-attribute and
text-content. text-content assigns textContent, and the text-field model
escapes markup on the way out and refuses a change in child structure, so a
styled span had no route into a composition file.

Adds a rich-text operation with one, guarded by a single sanitizer called on
both ends of the trip: in the browser so the preview shows what will be
saved, and on the server because that is where the file is written. Tags and
style properties are a small allowlist, and an unexpected tag loses its
formatting rather than its words. Spans an edit adds get their ids in the
same write, so a follow-up write cannot race it.

No UI yet — this is the persistence contract the editor is built on.

* fix(core): document and test the sanitizer boundary

* fix(core): harden rich text sanitizer traversal
2026-08-11 02:03:47 -04:00
Miguel Ángel fceb376551 fix(studio): match the write receipt in dev, so an edit stops reloading the preview (#3206)
## What

Editing anything in the canvas on the dev server reloaded the preview iframe. It no longer does.

## Why

The write receipt exists to prevent exactly this: Studio marks its own writes so the file-watcher echo can be told apart from somebody editing the file underneath it. The receipt is matched on the file's current bytes as well as its path, so `consumeFileWriteReceipt(absPath, expectedVersion)` takes a version.

The dev plugin called it with the path alone. `expectedVersion` was `undefined`, the version comparison never matched, and so every Studio write looked external and reloaded the preview. The CLI server — which is what ships — has always passed the version, so this is dev-server only.

## How

The plugin reads the file and passes its version, the same way `studioServer.ts` does, and treats a deletion (no readable bytes) as unmatched.

## Test plan

Driven on the dev server against a real composition, with `hf-reload-debug` on:

- Before: a drag logged `file-change` with a full external path, then `reload`, then `refreshPlayer`, and the iframe navigated — one reload per edit.
- After: the same drag logs `file-change` carrying the write token, then `suppressed: own write token`. Iframe reloads are zero across drag, resize and an inline text edit.
- Full studio suite (3727), format and lint green.

Found while chasing a flash after every canvas edit. The other half of that flash was Vite's own HMR full-reloading the page, fixed separately in #3163; with both in, the canvas stops flashing.
2026-08-11 01:37:35 -04:00
Miguel Ángel 20d915938b feat(studio): ask for feedback when a render ends, not on a session counter (#3205)
## What

Studio's feedback prompt now fires when a render finishes or fails, instead of on a session counter, and the reports it collects carry enough context to act on.

- Replaces the 32px inline bar with a card in the existing toast stack
- Adds `studio_feedback_shown` / `studio_feedback_dismissed` / `studio_feedback_interview_click`, so the funnel is visible
- A failed export and a crash skip the 0-10 score and ask what happened
- Adds a crash prompt to the error boundary
- Attaches a breadcrumb trail, render settings and outcome, and how the project was created

## Before / after

<img width="1500" alt="Before: a 32px feedback strip pinned under the preview. After: the feedback card in the toast stack, with one-tap answers, and the red variant for a failed export." src="https://github.com/user-attachments/assets/29e2373f-ee96-42c4-b006-ab9a0e56a60a" />

The old bar's rating numbers are `neutral-600` on `neutral-900/80`, which is why it reads as a disabled row rather than a control.

**In the running Studio** — bottom-right, sharing the toast stack, hovering a chip explains it on the line above the input:

<img width="1500" alt="The feedback card in the bottom-right of the running Studio, with the rotated follow-up question and a hovered chip explained inline." src="https://github.com/user-attachments/assets/3969da50-cc7a-466a-bbf9-b151a1bc1d5d" />

**On the crash screen** — the prompt the error boundary renders, asking what the user was doing rather than what went wrong, since the stack trace already covers the latter:

<img width="1200" alt="The Studio crash screen with the feedback card below the Try again and Reload Studio buttons." src="https://github.com/user-attachments/assets/4d287011-9c19-4b94-99d0-07a0388fb39c" />

| | Before | After |
|---|---|---|
| **Trigger** | Every 10th session | A render finishing, failing, or a crash |
| **Placement** | 32px inline bar, pushes preview up | Card in the toast stack, no layout shift |
| **Rating targets** | 11 bare buttons, `neutral-600` | Native radios, resting fill, `neutral-400` |
| **Follow-up** | Free text or nothing | One rotated question, four to seven one-tap answers |
| **Option help** | None | Inline hint on hover and focus |
| **Press feedback** | None | `active:scale-[0.97]`, 150ms ease-out |
| **Keyboard** | No exit path | Escape closes, Enter sends, arrows move the rating |
| **Auto-dismiss** | 20s, always | 30s, cancelled the moment you interact |
| **Failure case** | Same NPS question | Its own question, no score, error quoted back |
| **Crash case** | Nothing | Prompt on the crash screen |
| **Visibility** | Submissions only | Shown / dismissed (with reason) / submitted / interview click |
| **Report content** | Rating, comment | Plus breadcrumbs, render settings and outcome, project provenance |


## Why

The old bar fired on a session count, so it interrupted at a moment with no subject: nothing the user had just done, nothing to have an opinion about. It also emitted nothing when it appeared or when it was dismissed, which made the collection rate impossible to diagnose. A prompt nobody answers and a prompt that never renders looked identical from the outside.

Visually it read as a disabled row: 11 buttons at 11px in `neutral-600` on a dark strip, with no resting affordance. And appearing mid-task pushed the whole preview stack up, which the old code carried a comment apologising for.

Separately, the reports it did collect were not actionable. A comment says what went wrong; it almost never says how to get there.

## How

**One trigger, one owner.** `feedbackTrigger` owns eligibility and nothing else does: once per tab, thirty days after an answer, seven after a dismissal, never when telemetry is off (prompting someone whose response we would then drop wastes their attention). `VITE_HYPERFRAMES_NO_FEEDBACK=1` still disables it entirely.

**One hook, every failure path.** The trigger watches the render job list rather than each of the four places a render can finish (server rejection, unreachable server, SSE terminal event, SSE connection drop), so paths added later are covered without touching the trigger. Renders loaded from disk history never fire it.

**Reuses what exists.** The card wears `StudioToast`'s glass treatment and joins its stack, so there is no second visual language and no new CSS. The rating row is native radios, which gives arrow-key navigation, grouping and labels for free.

**One question each, rotated across users.** A corner card that asks three things gets answered by nobody. Each person gets one follow-up with one-tap answers, explained on a reserved line rather than a floating tooltip (the card is 340px in a corner; a bubble above the chips lands on the question, below lands on the input). Detractors are never given a rotated question, because they already have a specific complaint. Every option was checked against the code: an option naming a feature Studio already has would collect taps meaning "I could not find it", which is indistinguishable afterwards from "it does not exist".

**Breadcrumbs cost one line.** Every studio event already flows through `trackEvent`, so recording the trail there needs no new instrumentation and stays correct as events are added.

**Provenance lives outside React.** A crash unmounts the tree, so it is captured when the project loads and read from module scope when the crash prompt renders.

### Privacy

Breadcrumbs and provenance carry names, enums and counts only. Values are copied from a fixed allowlist of short keys, and anything longer than a slug is dropped rather than truncated, so comments, file paths, stack traces and project titles cannot reach them even if a future event carries one. Tests assert this.

### Where these responses land

Studio feedback goes to PostHog and nowhere else, which is what it did before this change too.

Worth stating because the CLI behaves differently: `hyperframes feedback` also forwards to the backend feedback endpoint via `submitFeedback`, on top of its PostHog event. Studio has never used that path, before or after this PR, so if you read CLI feedback anywhere other than PostHog, Studio responses will not show up there.

Nothing here changes that either way. Whether the two surfaces should share a delivery path is a product question, not a defect in this change, and closing it would need a field on the backend DTO: it is shaped around `cli_version`, and Studio reports from a crash or a failed export deliberately carry no rating.

## Test plan

- [x] Unit tests added/updated
- [x] Manual testing performed
- [ ] Documentation updated (if applicable)

**Unit** — 39 new tests: trigger eligibility and cooldowns, the detractor override, rotation, preset shape and the no-brands rule, breadcrumb rolling and privacy, provenance parsing and its failure modes, and the crash boundary rendering the prompt with no rating input.

**Live** — both render paths driven end to end against a running Studio on a production bundle, with real renders. Every PostHog request was intercepted and dropped, so nothing reached the project. Verified the emitted payload for a finished render, a failed export, the rotated follow-ups, each chip's hint, and the interview link.

**Not covered** — no live capture of a spontaneous crash. Three attempts to force one failed because Studio's guards held and it kept rendering, so the crash path is verified by component tests rather than by driving it. Touch devices see chip labels without hints, since the hint is revealed on hover and focus.
2026-08-11 01:33:52 -04:00
Miguel Ángel dc4383113c fix(producer): mix audio into a container that can record encoder delay (#3200)
* fix(producer): mix audio into a container that can record encoder delay

Every rendered composition's audio landed 1024 samples (21.33 ms at 48 kHz)
after its authored `data-start`, against a frame-accurate video track.

The mix is AAC-encoded, and AAC encoders emit ~1024 priming samples. The mix
was written to a raw ADTS `.aac` file, which has nowhere to record that delay,
so it decoded as real leading silence and every stage downstream preserved it
faithfully. Measuring each intermediate localises it precisely: the source WAV
is exact, the mixer's own output is already 21.33 ms late, and the pad/trim and
mux stages inherit it unchanged. The filter graph itself is correct - run by
hand to PCM it lands on the authored start.

Switch the artifact to an MP4-family container, which stores the delay as an
edit list that decoders strip. Same codec, same bitrate, so no size or quality
change.

The filename is a contract shared by three consumers - the mux input, the
distributed plan artifact, and the PNG-sequence sidecar handed to users for
NLE ingest - and its extension is what selects the muxer. Give it one owner in
the engine rather than five literals, so those consumers cannot drift onto
different containers.

Note for reviewers: this renames the distributed plan's audio artifact, which
is an on-disk contract between the plan writer and the assembler. Both move
together here, but a plan written by an older build would not be found by a
newer assembler. Flagging in case that mixed-version window matters for how
these are deployed.

* fix(cloud): read the plan audio artifact name from the producer contract

The aws-lambda and gcp-cloud-run adapters each restated the plan's audio
filename in five places, so renaming it in the producer left them looking for a
file that is no longer written. CI caught it: the gcp dispatch test asserting a
plan has no audio artifact started seeing one.

Export the name from `@hyperframes/producer/distributed` and consume it in both
adapters. This is the same failure the constant exists to prevent, one package
boundary further out: a literal that drifts from the writer's is a silently
missing audio track rather than a loud error, because both call sites only ever
ask whether the file exists.

* fix(cloud): accept a legacy plan's audio artifact name for one release

Review raised a rolling-deploy window I had flagged but left undecided: `plan`
and `assemble` are separate invocations bridged by object storage, so a
pre-rollout planner can be paired with a post-rollout assembler. Both readers
locate the artifact by existence alone, which makes that pairing a silently
muted video rather than an error. That is reachable enough to be worth two
lines, so reads now accept the old name while writes only ever emit the new one.

Give the fallback one owner (`resolvePlanAudioPath` / `isPlanAudioArtifactPath`)
rather than four call sites, marked for deletion one release out.

Also fixes a hole in the first pass of this: the plan-v2 materializer matched
either name but then joined the CURRENT one, so a legacy plan resolved to a path
that was never written. It now joins the artifact's own name.

Review nits in the same pass: correct the pad-branch docstring, which still
described a concat-copy shape the pad branch stopped using when it moved to
apad + re-encode, and fix the Windows fixture's stale `.aac` output extension so
it cannot model a shape that reintroduces the priming delay.

* test(producer): rebake the missing-host-comp-id golden without the audio delay

The pinned reference was rendered before this branch, so it carries the 1024
sample encoder-priming delay in its audio. With the delay gone the correct audio
now sits ahead of the reference and the harness's envelope correlation drops
below its floor.

Cross-correlating the old and new references at native 48 kHz gives a lag of
exactly 1024 samples (21.33 ms) at a correlation of 0.99985: same audio, moved
by exactly the amount this branch removes. Regenerated inside the CI container
(Dockerfile.test, ffmpeg 5.1.9) rather than natively, so the reference matches
the encoder CI will compare against - the container reproduced CI's failure to
the digit (correlation 0.3938764027803616, lagWindows -12) before the rebake and
passes at correlation 1.0 after it.

Note for archaeology: the new reference is also 3 dB louder than the old one.
That gap is not from this branch - `main` and this branch render the fixture at
the same level - it is pre-existing drift the reference had accumulated, which a
scale-invariant correlator could never see. The rebake absorbs it.

Only output.mp4 is updated. `--update` also rewrites compiled.html, but that
diff is embedded-font churn with no bearing on the comparison, which reports
"Failed at compilation: 0" either way.

* test(producer): rebake the variables-prod golden without the audio delay

Same cause as the missing-host-comp-id rebake, caught by shard-8 once the
earlier shard stopped failing and the rest of the matrix could run: this
reference also carries the encoder-priming delay this branch removes.

Reproduced in the CI container to the digit (correlation 0.42704173048439215,
lagWindows -12), rebaked there, and it now passes at correlation 1.0.

Worth recording: the shift here is 2048 samples (42.67 ms) at correlation
0.99983, exactly twice the 1024 of the other fixture. The delay compounds once
per un-compensated AAC generation, and this fixture's audio needs its duration
normalized, so it takes the pad/trim branch's re-encode and picks up a second
frame of priming on top of the mixer's. So the pre-fix error was not a fixed
21 ms - it grew with the number of times the audio was re-encoded.

All nine shards ran in that CI round with only this one failing, so the matrix
has now covered every fixture against this change.
2026-08-11 00:12:43 -04:00
Miguel Ángel eee9b26fb7 fix(core): key the preview volume envelope to the clip, not the timeline (#3198)
A GSAP volume fade on an audio clip that starts after t=0 left the preview
silent for the clip's whole length while the encoded render was correct.

`mediaVolumeEnvelope` is meant to keep preview and render on one envelope, and
its contract is "normalise, then read with track-relative seconds". The preview
skipped both halves. `probeElementVolumeKeyframes` stamps each keyframe with the
TIMELINE seek time it sampled at, and `normaliseEnvelope` — the function that
rebases those onto the track — had exactly one caller, the renderer's PCM baker.
The preview handed the raw keyframes to `interpolateVolumeGain` along with
`relTime`, so for a clip at t=2 every lookup fell two seconds before the first
keyframe and clamped to its volume: 0 for a fade-in.

Rebase once, where the cache is filled, so the cached envelope has a single
documented time base. Read it with elapsed-time-in-clip rather than `relTime`,
which is a position inside the media SOURCE — it carries `mediaStart` and the
playback rate, and only coincides with the envelope's time base for an
untrimmed clip playing at 1x from zero. That second half also fixes a latent
sibling: a trimmed clip read the wrong envelope point even when it started at 0.
2026-08-10 23:38:32 -04:00
Miguel Ángel 91f14958cc fix(core): stop a graded plate painting through an inactive clip (#3196)
A color-graded image or video inside a timed sub-composition kept painting
after its clip window closed. The runtime hid the sub-composition wrapper with
`visibility: hidden`, but the grading canvas carried an explicit inline
`visibility: visible`, and an explicit value on a descendant escapes an
ancestor's inherited `hidden`. The treated plate composited over whichever
scene was actually on screen, in preview and in the encoded render alike.

`drawEntry` only refreshed its cached view of the source's visibility inside
`if (injectedFrameSource || !hiddenByColorGrading)`. That gate exists for
opacity: `hideSourceElement` sets `opacity: 0 !important` on the source while
grading is active, so mirroring the source's computed opacity onto the canvas
would blank it. Visibility was swept into the same gate by accident. Grading
never writes `visibility`, so the source's computed visibility always tracks
the clip window — and because every graded source is hidden-by-grading, the
mirror could never self-heal once it went stale.

Split the two mirrors: opacity stays gated, visibility is re-read from computed
style every frame. Deriving it from the source rather than from a notification
means any way of hiding a clip works, including ones that do not exist yet.
2026-08-10 23:37:52 -04:00
Miguel Ángel c9dd8413c3 chore: release v0.7.106 (#3197) v0.7.106 2026-08-10 22:53:34 -04:00
Miguel Ángel 0156b5688d chore: regenerate the skills manifest (#3203)
The hyperframes-cli skill gained the search-miss report and its content hash
moved with it. The manifest is the published freshness fingerprint, so it has
to be regenerated in the same change.
2026-08-10 22:44:48 -04:00
Miguel ÁngelandMiguel Angel Simon Sierra 1ae2067b8d feat(catalog): put the variables panel back, on payloads (#3199)
* feat(catalog): put the variables panel back, on payloads

The panel drove its preview by loading an .html from docs/public, a type the
host does not publish, so it showed an empty frame in production and was
parked when the catalog was re-landed.

It now mounts the same JSON payload the plain player uses and re-mounts it as
values change, injecting them as window.__hfVariables into the composition head
before any of its scripts run, which is where the runtime reads overrides from.
Doing it in the markup rather than after load is what stops the composition
initialising with the wrong values first.

172 items with variables get the panel back; the playhead carries across a
change so a tweak mid-shot does not jump back to frame zero.

* fix(docs): drop the unused url form and the needless escapes

* fix(docs): the panel cannot reference a binding beside the export

* feat(catalog): make importing an SVG the obvious move

A reader arrives at this control with a shape, not with path data, and the
panel asked for the coordinates first. Import is now the primary action in a
drop target you can see is a drop target, and the raw path sits behind a
disclosure for anyone who wants it.

* feat(cli): let a fruitless catalog search report the gap

An agent that searches by meaning and finds nothing worth installing knows
something we do not: the name of a move the catalog is missing. There was no
way to tell us, so that knowledge was lost at the end of every run.

hyperframes feedback --search-miss "<query>" --wanted "<the move>" records it.
It carries no rating, so it never lands in the rating metric, and it is a
separate deliberate command rather than something catalog --query does on its
own: plain search still sends nothing, which is what the CLI promises.

--rating stops being required at the arg level, since a miss has no rating to
give. The check moved into the run body, where an absent one is now handled
rather than crashing on undefined.

* feat(cli): carry tuned variable values into the install snippet

Someone who tunes a block on its catalog page had no way to keep those values:
the install command was the same one everybody gets, and the tuning stayed on
the page.

hyperframes add <item> --vars '<json>' now prints a mount element carrying
data-variable-values, so the values land where the block is used.

They ride on the host rather than being written into the installed file. That
keeps the composition on disk byte-identical to the registry's, so a later
reinstall can still tell an edit from an update, and it lets two mounts of the
same block carry different values.

* fix(catalog): serve the item's own directory so runtime paths resolve

Some compositions assemble their asset URLs at run time —
"compositions/components/" + texture + ".png" for the texture masks, a font the
compiler pulled into _remote_media — and no scan of the markup can see a string
that does not exist until a script concatenates it. Those items either rendered
black or were dropped to a video that had never been uploaded.

Each item that needs it now has its prepared directory published, and its
payload carries a <base> pointing at it, so any relative path the composition
invents resolves. caption-texture renders its masks again, and
variable-font-flex has a preview at all for the first time: its MP4 and poster
are both 403.

Both layouts are published, because which one a composition asks for differs
per item, and a directory only earns that if it is under 2 MB. The 12 MB
texture sheet keeps the recorded video it already had.

* fix(catalog): let the variables panel actually drive the composition

Every control on the panel was inert. The values reached the composition and
nothing repainted, because the payload had already been compiled: compiling
inlines a mounted component and resolves its variables into the markup and CSS,
so by the time a reader turns a knob there is nothing left to change.

An item that declares variables now ships uncompiled, keeping the mount the
runtime loads at run time, which is the only state where data-variable-values
still means anything. The component travels inline as a data URI rather than a
sibling file, because .html is the one type the docs host will not publish. The
demo's own pinned values come off, so the reader's choices reach the mount
instead of losing to the values the demo picked to show itself off.

Measured on the rendered frame rather than the DOM: green rgb(98,207,144),
blue rgb(6,6,199), violet rgb(177,147,230), and back to green.

docs/public/catalog drops from 48 MB to 35 MB along the way, since an
uncompiled payload carries far less than an inlined one.

* feat(catalog): keep variable changes in the url

A reader who tuned a piece lost it on reload, and had nothing to send anyone.
The values now live in the query string, scoped by composition id so two links
never read each other,and only the ones that differ from the defaults are
written, so changing one knob gives a short URL rather than every variable
spelled out.

replaceState rather than pushState: dragging a slider should not leave a trail
of history entries. An unreadable value is ignored rather than thrown, so a
truncated or hand-edited link opens the piece at its defaults.

* fix(catalog): only rewrite the url when a value actually changed

* refactor(catalog): memoise the declared defaults on their content

* feat(catalog): offer an install command carrying the tuned values

The Install block is generated before anyone touches a knob, so it can only
ever print the plain command. Someone who spent a minute tuning a piece copied
it and got the defaults back.

The panel now carries its own command in the Snippet tab, with --vars holding
exactly the values that differ. An untouched piece still offers the same short
command, so nothing gets noisier for the common case.

* fix(catalog): a piece with nothing to render is a skip, not a failure

caption-blend-difference is a stylesheet and a paragraph of prose — a class you
add to your own captions, with no standalone scene to show. The generator
treated that as a build failure, so every run ended by reporting something
broken when nothing was.

It now reports the shape it is and keeps its recorded video, which is the only
honest preview such an item has. A genuine render failure still throws.

* fix(catalog): restore variables from the url on a cold load

A shared link opened at the defaults. The first render happens on the server,
where there is no window to read the query string from, and React then hydrates
against that markup and never revisits it — so the values only appeared once you
touched a control.

The URL is read again after mount, which is the first moment it exists. The
value is also escaped once now rather than twice: URLSearchParams already
decodes on the way out, and decoding a second time turned an SVG path full of
percent-escapes into something that no longer parsed, besides doubling the
length of every link.

* fix(catalog): mount the preview with the values a link carried

The frame was built from the declared defaults and the shared values were
posted to it afterwards, which is too late for anything the composition reads
once at init: a path arrived after the mark had already been drawn from the
default one, so a link looked right in the panel and wrong on screen.

* feat(catalog): the install command follows the values you tuned

Copying the Install line gave the plain command back, because that block is
generated before anyone touches a knob and had no way to know what changed. The
tuned command only existed in the panel Snippet tab, which is not where anyone
looks for it.

The line now reads the same query string the panel writes, so the two agree
without either component knowing the other exists, and a shared link carries the
right command too. replaceState fires no event, so the panel announces its own
writes.

* fix(catalog): send a text variable to the preview once it is finished

Every other control in the explorer reports a whole value on every event: a
slider at any position is a position, a swatch is a colour. A text field is
not. Typing v3 into a badge posted v first, so the preview remounted and
rendered a composition built from half a word.

The post now waits while a text field has focus and goes out when the edit is
committed, with Enter or by clicking away. The field itself is unchanged and
still tracks every keystroke.

---------

Co-authored-by: Miguel Angel Simon Sierra <miguelangelsi07@gmail.com>
2026-08-10 22:40:01 -04:00
Miguel Ángel 08934bfd55 revert(cli): keep HeyGen API traffic on stable prod (#3202)
* Revert "fix(cli): route HeyGen API calls through canary (#3201)"

This reverts commit 5545521556.

* fix(cli): remove remaining EF canary routes
2026-08-10 21:41:21 -04:00
Miguel Ángel 5545521556 fix(cli): route HeyGen API calls through canary (#3201) 2026-08-10 21:26:46 -04:00
Miguel Ángel ce18acf075 feat(catalog): group the sidebar by what you came to make (#3194)
* feat(catalog): group the sidebar by what you came to make

Nineteen shelves in one column is a list to read, not a menu to scan, and two
of them were oversized: Effects held 54 items and Showcases 38, two thirds of
which were near-identical code-snippet themes.

The shelves now sit under eight openable sections named for what a reader is
making, and the editor and terminal themes get a shelf of their own. Every
existing shelf name survives underneath, so a bookmark into the catalog still
lands where it did.

Two things had to change to support nesting. The summary count walks sections
as well as shelves, and the filter that preserves hand-written groups now
recurses: a section holds groups rather than pages, so a check that only read
`pages` found nothing generated in one, kept it as if a human had written it,
and appended a fresh copy on every run.

* fix(catalog): type the nested page count

* fix(catalog): nest shelves inside pages so the sidebar renders them
2026-08-10 19:12:27 -04:00
Miguel Ángel 0c33b2dc7a feat(cli): keep your edits when you reinstall a catalog item (#3193)
* feat(cli): keep your edits when you reinstall a catalog item

Running add again overwrote whatever was on disk, so a project that had tuned
an installed block lost that work without being asked or told.

The installer now records a hash of each file as it installs it, and compares
before writing. A file that still matches is replaced as before; one that does
not is left alone and reported. A file we have no record of counts as changed,
which covers both a project that wrote the file itself and one that installed
before the record existed.

--force restores the old behaviour for when you do want the registry's version.

* test(cli): cover the dependency plan install path
2026-08-10 19:11:16 -04:00
Miguel Ángel 9734578e60 feat(registry): bring back the video-primitive moves (#3169)
Restores the 208 catalog items reverted after their previews 404'd in
production, this time on the payload mechanism rather than the .html files
that caused the outage.

The generator no longer writes a preview document to docs/public. That writer,
and the machinery under it, existed only to produce files the docs host
discards, so it is gone rather than bypassed. Items now embed the composition
itself via a payload, which is what the previous change already does for the
items that were already in the catalog.

The variables explorer is parked, not restored: it drove its preview through
the same unpublished .html path, so it would have shown an empty frame. Items
that declare variables get the live player plus the static variables table, and
reconnecting the explorer to payloads is a follow-up.
2026-08-10 18:46:03 -04:00
Miguel Ángel 0f76305191 fix(catalog): keep the recorded video for canvas drawElement previews (#3171)
Eight blocks paint DOM into a canvas with ctx.drawElementImage(), which sits
behind chrome://flags/#canvas-draw-element. A reader without the flag gets a
preview that mounts, plays, and shows an empty canvas, which is what the 3D
device blocks were doing even after their models were embedded correctly.

The recorded video was captured by a renderer that has the capability, so it is
the only preview these items can honestly show. All eight have a published mp4
and poster.
2026-08-10 18:30:28 -04:00
Miguel Ángel 470f802d9f fix(catalog): resolve assets a script loads by name (#3170)
The 3D blocks fetch their model with loader.load("models/iphone.glb"), which no
attribute or url() pattern can see, so the path survived into the payload
untouched. Inside a srcdoc iframe it resolved against the docs page and 404'd,
leaving vfx-iphone-device and ios26-liquid-glass mounted and playing with an
empty scene.

Names that resolve to a real file in the item's own directory now count as
references. They are deliberately weaker than markup references: a name that is
not a file is ignored rather than failing the item, so an ordinary string that
happens to look like a filename cannot push an item onto the video fallback.

Also clears an item's payload when a run decides it cannot build one, so the
page generator stops finding a previous run's file on disk and emitting a
player for a preview that no longer exists.
2026-08-10 18:16:02 -04:00
Miguel Ángel 536165b6ef feat(catalog): play the real composition on catalog pages (#3168)
* feat(catalog): play the real composition on catalog pages

Catalog previews were uploaded MP4s, published by hand to the CDN. Each page
now embeds the composition itself, running in <hyperframes-player>, so a
preview is the block rather than a recording of it.

The composition is delivered as JSON under docs/public/catalog, and the player
is mounted inside an iframe. Both are forced: the docs host publishes only JSON
and images out of docs/public, and its MDX renderer strips unknown custom
elements, so a player written into the page never reaches the DOM and an .html
payload 404s in production.

Assets are inlined as data URIs, which sidesteps the file-type restriction for
fonts, scripts and models alike. 164 of 168 items build a payload; the rest
keep their MP4 and say so.

* chore: rebuild docs preview

* chore: route isolation probe

* chore: drop the route isolation probe page

* chore: probe which asset types the docs host publishes

* refactor(catalog): host preview assets instead of inlining them

Fonts were being base64'd into every payload that used them, so a handful of
files cost tens of megabytes in the repository to say the same thing over and
over. Assets are now written once, content-addressed, under
docs/public/catalog/assets and linked.

Which types can be hosted was settled by fetching one file of each from a
deployed preview: woff2, wav, mp4, svg and the image formats are published,
glb is not. Types the host drops still travel inside the payload, because a
link that 404s is worse than a larger payload.

Also fixes the four type errors the scripts typecheck caught, all of them
unchecked index access on a split() result.

* refactor(catalog): share preview assets instead of repeating them

* style: format the asset module
2026-08-10 17:54:48 -04:00
Miguel Ángel 3e5be0e8c3 fix(studio): read the rotate property when measuring an element's angle (#3163)
* fix(studio): read the rotate property when measuring an element's angle

Turning an element with Studio's rotate handle left every piece of overlay
chrome square across it: the selection box, the crop outline and the child
outlines all drew upright while the element underneath was clearly rotated.

The handle writes the CSS `rotate` property. `rotate` is an individual
transform property, not part of `transform`, so `getComputedStyle(el).transform`
reports nothing for it and both places that measure an element's angle — the
overlay geometry and the crop frame — read the element as upright.

Both now read `rotate` alongside `transform` and compose them the way CSS
does, individual properties first. A rotation about any axis but z has no
single in-plane angle, so it reports nothing and the caller keeps its
axis-aligned fallback rather than drawing chrome at a plausible wrong angle.

* fix(studio): stop the crop outline refusing the transforms GSAP writes

The crop outline still drew square on a rotated element after the rotate-
property fix, because it refused the transform outright: it accepted only
`matrix(...)`, and GSAP writes `matrix3d(...)` for an ordinary 2D move or spin
(force3D). A composition that mirrors an element writes one with a negative z
scale, and the negative determinant that follows was refused too.

Both are ordinary planar transforms. The outline now reads the same 2D
projection the rest of the chrome takes through DOMMatrix, and sizes a
flipped element from the magnitude of its determinant. Only a perspective
term still falls back, because that is where the mapping stops being affine
and no single angle describes it.

The test that asserted "a 3D matrix means give up" asserted the bug: its
fixture was the identity written as matrix3d, which is as planar as a
transform gets. It now checks the behaviour that replaced it, alongside the
perspective case, which still falls back.

* fix(studio): draw the crop outline at the angle the element paints under

Selecting a text layer inside a rotated card drew its crop outline across the
text at roughly a right angle. The outline read the element's own transform,
but what the user sees is that composed with every ancestor's — the layer
carries its own spin and its parent turns it again.

It now walks to the composition root and composes each level, the element's
`rotate` property before its `transform` and an ancestor outside its child,
which is the order CSS applies them in. Nothing transformed anywhere still
falls back to the caller's axis-aligned rect, since that comes from real
layout and describes the element exactly.

The chrome test stubbed getComputedStyle to answer "rotated 30deg" for every
node in the document, so composing read the same turn once per ancestor. The
stub now answers per element, which is what it always meant.

* fix(studio): stop the dev server reloading the page on every canvas edit

A composition lives under this package's root, so Vite's HMR saw a write to
one as an html page dependency changing and full-reloaded the browser. That
reload is the flash after every edit in the canvas: the whole app remounts,
taking the preview iframe with it.

The decision was never Vite's to make. Studio already knows whether a write
was its own — that is what the write receipt is for — and refreshes the
preview itself when it needs to. Vite's watcher now ignores the project data,
and the dev plugin watches it on a watcher of its own, announcing changes as
hf:file-change exactly as before.

Measured on a drag: Vite full reloads went from one per edit to none, and the
receipt now reports 'suppressed: own write token' where it previously never
saw a matching path.

* refactor(studio): compose an element's transform in one walk, not two

Review: the crop frame hand-composed ancestor matrices while the geometry
file did the same walk through DOMMatrix. Both were right, but the next
individual transform property CSS grows — `translate`, `scale` — would have to
land in both, and a miss puts the crop outline back at the wrong angle while
the selection box draws the right one.

The walk now lives in one place and takes the arithmetic as a parameter. The
geometry file keeps DOMMatrix, because it goes on to transform corner points
and needs the translation; the crop frame keeps plain 2D components, because
it only needs an angle and a scale. Which transforms count, and in what order,
is stated once.

Also from review: the nested case was verified by hand only, so the composed
walk is now covered on both sides — a child inside a rotated parent reports
the angle it paints at, the parent's rotation alone when the child has none,
and the walk stopping at the composition root. And `hasAttribute?.` was dead
on a narrowed HTMLElement; it only survived because the crop test's fake
element was not one. The fake now models an element and the guard is gone.

* style(studio): format the shared transform module
2026-08-10 17:41:34 -04:00
James Russo 945988d61f docs(changelog): weekly digest 2026-08-03–2026-08-10 (#3154)
* docs(changelog): weekly digest 2026-08-03–2026-08-10

Rewritten from `git log --no-merges` grouped by type and scope, not polished
from the generator draft. The generator's picks were a 10-item list that put
two internal tooling commits in Highlights, split the router story across
three bullets that supersede each other, and covered none of the week's 70
fixes or 76 docs commits. This cites 109 commits and 45 PRs.

Verified at HEAD rather than from commit bodies:
- The DE parallel router is default-on for every install. The canary entry is
  gone from canaryRegistry.ts and render.ts no longer gates it. Framed as a
  restoration, since the canary had cut fleet exposure to 0.13 percent.
- Catalog meaning search is opt-in behind a 33 MB consented download, so it
  is not described as default behaviour.
- The Reference Project landed and was removed inside the same window, so it
  is reported as both rather than announced as new.
- 30 Days really does carry thirty distinct films. Examples now has 23, not
  the 19 its commit subject claims, so no count is published for it.

Every SHA is expanded from a token by a script that refuses to write unless
it resolves to 40 chars, prefix-matches, is an ancestor of origin/main and
falls inside the window. No SHA was hand-typed.

* docs(changelog): correct release range to v0.7.90 in weekly digest

v0.7.91 was never published. Its artifacts were folded into v0.7.92, so
the sequence goes v0.7.90 -> v0.7.92 and the window's first release is
v0.7.90. Still fifteen releases.

* docs(changelog): embed the weekly video in the Aug 3-10 digest

45.1s square film built from the changelog-video skill. Uploaded to
static.heygen.ai and the CloudFront path invalidated.
2026-08-10 16:11:42 -04:00
Miguel Ángel a58ebf610b style(studio): format AGENTS.md (#3167)
oxfmt formats markdown, and the file added in #3165 was not run through it.
main's Format check has been failing since that merge, which also fails every
open PR, since CI checks the merge with main.
2026-08-10 15:51:43 -04:00
Miguel Ángel 604f02b31a docs(studio): write down what Studio does not tell you about itself (#3165)
Working in packages/studio for the first time costs a day rediscovering things
the source does not show: that the chrome is a measurement drawn in Studio's
document over an iframe, that some gestures cannot be synthesised at all so a
driver needs window.__studioTest, that the diagnostic channels exist and are
off by default, that bare `bun test` reports failures that are not real, and
which gates reject a PR.

Scoped to the package, following docs/AGENTS.md, and pointed at from the
project-structure list in both root files so it is found before the first edit
rather than after.
2026-08-10 15:11:09 -04:00
Miguel Ángel c86d4013f5 Revert "feat(registry): the video-primitive moves, documented and customisable (#3090)" (#3162)
This reverts commit 3b53bfd2f7.
2026-08-10 14:47:47 -04:00
Miguel Ángel 3b53bfd2f7 feat(registry): the video-primitive moves, documented and customisable (#3090)
* feat(registry): add the video-primitive moves, and rebuild the catalog around them

Adds the motion primitives: 277 new components and the blocks that go with
them, plus the ui-primitives, themes and generators they are produced by. The
registry index goes from 176 items to 454, and the search catalog is rebuilt so
the set that is ranked is the set that can be installed.

Additive on purpose. An earlier pass of this port used rsync --delete, which
removed 101 files that exist on main because the incoming set is not a superset
of the current one: beat-freeze-cut and camcorder-hud among them. Whether the
re-port replaces those or sits alongside them is a product decision and not one
a sync flag should make, so nothing is removed here. If any of them are meant
to go, that belongs in its own commit where it can be seen.

The generator is ported too. Main's version only scans examples, so running it
without this change silently rewrote the index down to nine items. It also
rewrites example manifests from templates.json and will overwrite hand-edits;
those were reverted here after each run, and the diff is worth reading rather
than trusting.

Not covered. The 445 moves are not individually reviewed in this commit; the
machinery that ranks and installs them landed separately so it could be read on
its own. The internal evaluation corpus is deliberately absent: it is 1,400
files of briefs, gold labels and verdicts, and this repository is public.

* docs(catalog): publish the primitive and component pages

Adds the Mintlify pages for the moves this PR ships: 163 component pages, 13
primitive pages, and the navigation that lists them. Without these the moves
land installable and undocumented, which is the worse half of a catalog.

Three things left out deliberately.

The 78 MB of docs/public. Nothing references it: every page loads its preview
from static.heygen.ai, so those bytes would be weight in a public repo with no
reader. Checked rather than assumed, by grepping the pages for the path.

Pages for the thirteen moves that were specified and never built. They had
documentation but no registry item, so a reader would have followed a page to a
`hyperframes add` that fails. Their nav entries are pruned with them, and every
one of the 309 remaining catalog and primitive nav entries was verified to point
at a page that exists.

Spike and scratch files that sit alongside the real docs on the source branch:
qa-gallery.html, experiment pages, bundled player javascript. They are working
artifacts, not documentation.

Not covered: the pages are generated output and have not been read individually.
The nav is verified to resolve, and the previews load from a CDN this commit
does not control, so a broken image would show up in review rather than here.

* docs: list the primitive and component pages in the site navigation

The pages this PR adds were unreachable: nothing in docs.json pointed at them.
This appends a Motion primitives group to the existing Catalog tab and a
Primitives tab, both built from main's navigation rather than replacing it.

Copying the source branch's docs.json wholesale was the first attempt and was
wrong. That file describes a different site, tabs Documentation / Catalog /
Primitives / Packages / SDK / Reference against main's Guides / Studio /
Catalog / Developers, and it references pages only that branch has, so the
preview server reported six dead links.

Verified by running the preview and resolving every entry: 484 page refs, 0
dead, no warnings. Group-relative refs are why a flat existence check is the
wrong validator here: cursor resolves through catalog/components and mcp
through guides, so checking docs/<ref>.mdx flat pruned 22 entries that were
fine.

* fix(registry): restore what the port took from main's components

Two regressions this branch introduced into items main already ships. Both were
found by the repo's own gates in packages/cli, not by reading the diff, and
neither is visible to the no-deletions check: no file was deleted, the contents
of files were changed.

The four liquid-glass blocks stopped installing their library. main lists
lib/liquid-glass.iife.js as a second file on each; the port wrote the older
manifest over main's and dropped that entry. The file is still in git and still
on disk, it simply stopped being something `hyperframes add` writes, so the
installed composition's <script src="lib/liquid-glass.iife.js"> would have
resolved to nothing. Every one of the 294 registry-item.json files this branch
touches was then audited against main: these four lost a file entry, and no
item lost a top-level key.

Fourteen caption components gained an empty <video>. The port added
`<video id="wp-video" ...></video>` — no src, no <source> — to each component
and its demo. It renders nothing and the registry linter rejects it as
media_missing_src. Removed rather than given a placeholder, because main's
version of each of these composes over whatever the host composition provides,
so the element only ever added a broken node; a made-up src would ship a
reference to footage that does not exist.

The removal is deliberately surgical. Four of the fourteen also carry a
substantial rewrite from the port, and only the media element and the rule that
styled it are touched, so a blunt revert cannot take the rewrite with it.

Verified: 2540 CLI tests pass, `bun run lint` exits 0. Before this, three tests
failed.

* docs(catalog): play the real composition, and show what can be changed

Four changes to generate-catalog-pages.ts, so all 445 pages stay consistent
rather than 445 files being edited by hand.

The preview plays the composition instead of pointing at a video. Every new
page pointed at static.heygen.ai/<name>.mp4 and every one of those answered
403, so the reader got a black box where the whole point of the page is to
show them the thing. The objects were never uploaded and rendering 445 of them
would have to happen again on every change. The player is already the thing
being documented, so the page embeds it: the item's directory is copied under
docs/public and an iframe loads it through a small wrapper. 444 of 445 pages
play; the remaining one is a texture item that uses its own preview panel.

The iframe is not decoration. Compositions set styles on `body`, so dropping
the element straight into the MDX would put a composition's global CSS in the
same document as the documentation around it.

Three things this got wrong first, all found by opening the page rather than
reading the output:

  - The wrapper loaded itself. `../<dir>/<name>.html` from inside preview/<dir>/
    resolves back into preview/<dir>/. The player embedded the player and the
    frame went black with a second set of controls shrinking into the corner.
  - Copying only demo.html was not enough. Most demos are a mount shell whose
    child carries data-composition-src="./<name>.html", so the sibling has to
    come with it. Every URL answered 200 and the frame was still empty.
  - `autoplay` and `loop` are not player attributes. Writing them did nothing
    and every preview sat paused on frame 0 — which is blank for any
    composition that animates in. The wrapper drives play() and loops on
    `ended` instead.

The Variables table. generateParams reads `params`; every item ported from the
video-primitives work declares `variables`, a richer schema with a type, a
default and a range. 112 items carry one and not a single page showed it, so
the most useful thing on the page was the one thing missing.

Nav groups. `if (entry.type === "component") return "Effects"` was the
catch-all, so Effects held 267 of 445 pages: an alphabetical wall. Rules keyed
on tags that already exist in the manifests split it; the largest group is now
73.

An install command with a visible copy button. A plain code fence renders one
on hover only, and it was absent from the accessibility tree entirely. This is
the one line every reader comes to take. navigator.clipboard is unavailable on
insecure origins, which is exactly the local preview these pages are written
against, so the fallback path is load-bearing and is what was exercised in
testing.

Verified: 888 preview URLs fetched, 0 failures. Regenerating three times in a
row produces no change, after a first attempt where "Variables" was added to
GENERATED_HEADINGS with a capital V — the set is compared lowercased, so each
run carried the previous section forward and appended a new one.

Not covered: the 445 pages were not read individually. Coverage here is that
every preview resolves and that a page from each of the block and component
paths was opened and watched.

* docs(catalog): put the code on the page

A reviewer with no stake in the work compared these pages against shadcn/ui's
component pages and motion.dev, and returned one gap: the pages carry almost no
code, so they are pointers to a file the reader does not have yet. Its sharpest
example was the Variables table — names, defaults and accepted values, headed
"set the ones you want to change on the element", on a page that never shows an
element or the syntax for setting anything on one.

Two additions, in the generator so all 445 pages get them.

A snippet under the Variables table: the real mount element with
data-variable-values filled in from the item's own defaults, so it is
copy-and-run correct before it is edited. That is the syntax the demos actually
use, not an illustration written for the page.

The item's source, in a collapsed Accordion. These files run 99 to 463 lines,
so inlining them raw would bury everything else; collapsed, the code is on the
page and one click away. Accordion is already what these docs use for this.

A second reviewer, fresh, confirmed the change landed: it called the table and
snippet actionable rather than filler and said the collapsed source earns its
place.

Also here: the preview retries play() until the clock moves. `ready` can flip
before the runtime the player injects for a mounted sub-composition has finished
wiring up, and a play() landing in that window silently does nothing.

Both reviewers additionally reported every preview frozen at 0:00 and called it
fatal. It is not. The player's clock runs on requestAnimationFrame
(direct-timeline-clock.ts), browsers suspend rAF in a hidden tab, and the
reviewing tab was hidden: document.visibilityState read "hidden" while the
player reported ready and not paused, and a one-second rAF loop never completed
a single tick. Seeking the same composition by hand renders it correctly at any
offset. So the retry stops after ~15s instead of spinning forever, and the
comment says why an automated check of a background tab will always read 0.

Not covered: the reviewers' other standing finding, that only some items carry
variables at all, so the pages do not have one shape. 125 of the 206 items
tagged as a primitive declare none, and giving them variables means authoring
them into each composition, not editing metadata.

Verified: lint exits 0, the no-deletions gate passes, nav resolves 598 refs with
0 dead, and regenerating three times running changes nothing.

* feat(registry): give 55 primitives variables that actually do something

The catalog pages listed variables for 112 of 454 items and nothing for the
rest, so most pages could show a reader what a piece looks like but not what
they could change about it. This adds them to 55 more, taking the count to 167.

These are not metadata. A variable is only real if the composition reads it, so
each one is declared on the root, validated in the composition's own script, and
wired to something visible: travel distance, blur radius, direction, density,
accent family, tone, label text. Declaring a knob the code ignores would put a
table in the published docs that lies about the piece, which is worse than
having no table.

Every one falls back to its declared default when the incoming value is missing
or unrecognised, so a bad override degrades to the shipped look rather than to a
broken frame. With no overrides at all, each item renders exactly as it did
before: that was checked per item against `git show HEAD:` in a real browser,
comparing computed styles rather than eyeballing.

Four things this ran into that are worth writing down.

An apostrophe anywhere in a description terminates the single-quoted
data-composition-variables attribute and breaks the HTML parse. Every
declaration in the registry now parses; that is checked, not assumed.

Where a timeline drives GSAP's own y/scale/filter, GSAP writes inline styles
that beat any CSS custom property, so those knobs cannot be won from the
composition. Most of these items keep their motion in a user-owned "Timeline
integration" comment rather than in code, so no timing variables were declared
for them at all. A direction knob on a wipe can still be wired honestly, by
remapping clip-path inset sides through multipliers whose defaults reproduce the
original exactly.

Colour tokens that only reach a :focus-visible outline, or an element sitting at
opacity 0 at rest, render identically in a video. Those were skipped rather than
shipped as knobs that appear to do nothing.

CSS shorthand defaults need care: `border: var(--x, 0 solid transparent)` moves
computed border-color off currentColor even at zero width. Defaults were chosen
to reproduce the original computed style, not merely to look equivalent.

Not covered: 72 primitives still have no variables, and the UI-primitive demos
that scripts/sync-ui-primitives.ts mirrors are now stale for the converted
items. Nothing runs that script in CI today.

Verified: every declaration parses and deep-equals its manifest array, every
declared id is read by the composition, no demo.html changed, and lint exits 0.

* feat(registry): variables for 16 more primitives, and stop the snippet clipping

Takes the count from 167 to 183 of 454. Same contract as the last batch: each
variable is declared on the root, validated in the composition's own script,
and wired to something visible, because a knob the code ignores would put a
table in the published docs that lies about the piece.

The snippet under each Variables table was clipping. Its data-variable-values
payload is one long line and the code block cut it off mid-value, with no wrap
and no scrollbar, so the one line on the page that exists to be copied could not
be read. The fence now carries `wrap`. Worth noting how that survived: the
generated markdown was correct and every mechanical check passed. It only failed
in a browser, which is where it was eventually seen.

Two techniques this round that are worth keeping.

Where an accent has a themed token family, the knob sets a new custom property
consumed by that one surface, with a fallback to the existing token, rather than
overriding the shared accent. Default therefore sets nothing, so an externally
themed accent is not clobbered, and the non-default options still follow the
theme in both light and dark.

Where GSAP owns the property outright and no CSS multiply can win — number-wheel
animates `y` inline — the knob is wired at build time instead: extra revolutions
lengthen the digit strip and move the target, so travel changes while the resting
frame stays identical. That is a real answer rather than a skipped knob.

Motion knobs that multiply a timeline-driven custom property collapse to identity
at rest, so every one of them was verified with that property pinned to a
mid-flight value rather than at t=0, where all options look the same by
construction.

Not covered: 56 primitives still have no variables.

Verified: every declaration parses and deep-equals its manifest array, every
declared id is read by the composition, no demo.html changed, lint exits 0, and
the wrap fix was confirmed on the rendered page rather than in the markdown.

* feat(registry): variables for 21 more primitives

Takes the count from 183 to 204 of 454. Same contract: declared on the root,
validated in the composition script, wired to something visible, defaults
reproducing the pre-edit render exactly.

Three kinds of knob were turned down this round rather than faked, and the
reasons are worth keeping.

A knob that contradicts its own motion. The sheet panel could be moved to the
left, but the recipe drives GSAP x from the right, so the panel would slide in
from the wrong side while the control claimed otherwise.

A knob that needs two defaults. A separator length means width horizontally and
height vertically, so one token would be wrong half the time.

An option that is not an option. Two components were given a green accent that
probed byte-identical to their default, because the theme accent already is that
token. A row in the docs table that does nothing is worse than a missing row, so
it was replaced with one that differs.

Accent knobs set a new property with a fallback to the shared token rather than
overriding it, verified by rendering with an external accent in place and
confirming the default still yields to it. Motion knobs multiply a
timeline-driven property so they never fight the inline styles GSAP writes;
because those collapse to identity at rest, each was checked twice, once at rest
against HEAD and once with the driven property pinned mid-flight.

Verified: every declaration parses and deep-equals its manifest array, every
declared id is read, defaults match HEAD on computed styles and on a pixel hash
of the rendered element, no demo.html changed, lint exits 0, and the
no-deletions gate passes.

* feat(registry): variables for 4 more primitives, and make manifests agree with their compositions

Takes the count to 207 of 454.

Four items carried a different description for their exit variable in
registry-item.json than in their own data-composition-variables. The catalog page
renders the manifest, so the published table described the knob one way while the
composition header described it another. The composition wins: it is the file
that implements the variable and the declaration is what the runtime reads.

The skill docs no longer describe a hosted tier, since the CLI now ships the two
local tiers only, and skills-manifest.json is regenerated to match.

* feat(registry): variables for 4 more primitives

Takes the count to 211 of 454.

Two knobs are worth calling out because they touch things the timeline also
touches. skeleton-block slide multiplies the driven row offset, so it is
identity at rest and only bends the middle of the move. slider value sets the
resting fill together with the readout text, aria-valuenow and aria-valuetext,
so all three agree; a composition that tweens the fill takes over from there and
owns the readout, which the comment header states plainly rather than hiding. A
multiplier was rejected there because a 0 to 1 tween would push the fill past
the end of the track.

Knobs on elements that sit at opacity 0 at rest were kept only where the shipped
recipe reveals them, and verified with the reveal forced on as well as at rest.
Skipped: an aria-label string knob that never renders, and an accent token
declared in one item CSS that nothing consumes.

* refactor(registry): drop the UI primitives, this is a video catalog

Removes 66 items tagged ui-primitive: accordions, buttons, inputs, dialogs, a
calendar. They are a shadcn-style interface component set that happens to be
expressible as HTML. None of them animate anything, so in a catalog whose job is
to offer moves for video they widen the surface without making it more useful,
and each one is a page a reader has to skip past to reach something that moves.

Every one is new on this branch. None exists on main, so nothing main ships is
being taken away; that was confirmed against origin/main before deleting rather
than assumed, and the no-deletions gate still passes.

Removed with them: registry/ui-primitives, the Operator Black token and contract
files only these items consumed, and the tooling that maintained them
(sync-ui-primitives.ts and scripts/lib/ui-primitives). No other registry item
declares a dependency on any of the 66, so nothing else loses a piece. The now
empty UI Primitives navigation rule goes too.

Generated output is pruned with the sources. The page generator writes files but
never removes ones whose source has gone, so a stale page would have survived and
404d its own preview. Verified: 0 orphan pages, 0 orphan previews, and the
navigation resolves 532 references with none dead.

This does discard variables authored for 54 of them earlier on this branch. That
work is in the history if these ever come back, and it is the right trade: they
should not have been in a video catalog to begin with.

The catalog is now 388 items. Lint exits 0 and 2522 CLI tests pass.

* feat(registry): every motion and transition primitive is now customisable

The last 16 primitives get variables, so none is left without them. 173 of 388
items now declare variables; the rest are blocks and showcases, which are whole
scenes rather than parameterised moves.

Same contract throughout: declared on the root, validated in the composition
script, wired to something visible, and falling back to the declared default on
missing or unrecognised input. With no overrides every item renders exactly as
it did before, verified per item against the pre-change render in a real browser
at rest and at pinned mid-flight states, comparing computed styles and rects and
in most cases a screenshot hash.

This round refused several knobs rather than shipping ones that only look real.

A tilt-card depth knob was written, measured, and thrown away: the card sets
overflow hidden, which forces transform-style flat, so the authored translateZ
is already inert and every option probed identical. It ships a glow knob
instead, which drives inset and visibly changes at rest and under the drift.

slot-machine-roll has no free travel knob because the roll is exactly one row
height and any multiplier lands the reel off-register; size scales row height
and roll distance together, which is the only honest version. soft-blur-in
offers up and down but not left and right, because the shipped tween resets y
and not x, so a horizontal offset would never animate away.

Two pre-existing bugs surfaced while checking honestly, both left alone as out
of scope but worth recording. zoom-through-transition and tracking-in each tween
a custom property that is never set, so CSS reads it as zero and the move starts
from zero rather than from its authored value. The depth and tracking knobs are
scoped around that and their headers say so, rather than pretending the tween is
what it appears to be.

Verified: 388 items, 0 primitives without variables, 0 items where the manifest
and the composition disagree, 0 declared-but-unread variables, nav resolves 532
references with none dead, no demo.html changed, lint exits 0, and the
no-deletions gate passes.

* feat(registry): raise the catalog quality bar, and add eight primitives

Cuts 37 components, adds 8, and writes down the standard both decisions were
made against.

The 37 removals are all new on this branch and absent from main, so nothing
shipped is withdrawn. Each was audited with two pieces of evidence: source
identity after name normalisation, and a composition-level contact sheet
showing the members animate identically.

The largest group was 13 files byte-identical apart from an h3 and one
sentence. Nothing marqueed, panned, zoomed, deployed or dragged. An honesty
tiebreak decided survivors: frosted-glass-wipe has no backdrop-filter,
spring-scale-in has no spring, masked-slide-reveal has no mask,
short-slide-right travels up, and three-particle-ribbon differs from
three-orbiting-cards by one number while having neither particles nor a
ribbon.

Two independent audits agreed 10 out of 10 on a shared calibration sample,
in both directions, including three items a first pass wrongly condemned.

The rubric is the durable part. Fatal criteria are separated from fixable
ones, because no-timeline alone hits 97 items including some of the best;
promoting it would have cut 97 and left a worse catalog. It also records the
harness rules that make a verdict reproducible: render from the composition
rather than the demo, since demos carry content the installed item does not,
and mount sub-compositions rather than inlining them, since inlining renders
black frames indistinguishable from a dead item.

The eight additions target measured gaps. Camera language ranked first
because PSNR across 30 reference demos showed the most impressive
environments barely move: they are sets, not shots.

camera-shake carries nine lens-accurate profiles, amplitude scaled by focal
length so a wide lens shakes differently from a telephoto. rack-focus splats
each light through the aperture shape, so a defocused point becomes an image
of the iris, with flux conserved so highlights survive defocus.
camera-dolly-zoom solves focal length from distance, holding subject size to
0.000 percent drift while the background grows 53 percent. Plus
oscilloscope-trace with history-free phosphor persistence, bar-chart-race,
split-flap-board, spiral-galaxy and vfx-anamorphic-flare.

Each is verified by rendered frames and a seek-equals-playback check rather
than by check passing, which is not a visual gate.

* fix(registry): let the split-flap board finish flipping on screen

The board declared 8s but every flap had settled by 3.5s, so more than half the composition was a still frame and the catalog preview opened on it.

* fix(registry): keep the thread-message-stack payload parseable

A line wrap had put literal newlines inside the JSON string literals of the blocks data-hf-primitive-data payload, so JSON.parse threw in the browser and the composition never ran. The preview script hid it: it rewrote the payload in the temporary copy it captured from, so the catalog picture looked right while every installed copy stayed broken. That repair pass is gone and the payload is fixed where it ships.

Its two tests could not have caught this. Both were written against vitest in a directory the repo runs with node:test, so neither was in test:scripts and neither had ever run. They are converted and registered, along with a new one that JSON.parses every payload in the registry, and that one was checked against the re-wrapped shape before being kept.

* fix(registry): close the apostrophe that truncated a variables declaration

chromatic-aberration-wipe described its accent as "the incoming scene's gradient" inside a single-quoted data-composition-variables attribute, so the attribute ended mid-JSON and the tag never closed. The formatter refused to parse the file, which is how it surfaced, but the runtime would have read a truncated declaration.

Also formats the 159 registry and docs files the branch had left unformatted, regenerates the skills manifest, and drops docs/primitives: those 13 pages import /snippets/PrimitivePlayer.jsx and read docs/public/primitives/, neither of which is on this branch, so mint failed the build on them. Nothing links to them and they ship whole on feat-video-primitives.

CI ran test:scripts before building core, so the preview test added here failed on a missing dist rather than on anything it checks. It now runs after the builds.

* fix(registry): make the review findings real fixes

Ten items declared variables on their composition root but had no variables key in the manifest, so their generated pages shipped no explorer at all. Their manifests now mirror the root. Two more disagreed only in description text, and the root was the truthful side: both compositions paint an inset ring, not the slabs or colour pair the manifest described.

The caption <video> removal left 24 CSS rules addressing elements that no longer exist. Removed, excluding the four ids that were already orphaned on main.

thread-message-stack could not stay fixed: oxfmt reflows a divs contents and lands a newline inside a JSON string literal, so the payload broke again on the next format. A script tag is not an option because the runtime strips every script out of the mounted clone. The reader normalizes HTML whitespace instead, which is what makes it survive any reflow, and the guard test now asserts that contract rather than the byte layout.

downloadFile had lost its 30s timeout, DownloadOptions, and the mid-pipeline error plumbing in a rewrite that was only meant to fix redirects. Five callers were left with no stall guard. Restored, redirect handling kept.

warnUnknownEnumValues re-did the parse readDeclaredDefaults had already done. Both now share one readDeclarations, and the rest splits into compositionLabel, declaredOptions and unknownEnumValue. 1909 core tests unchanged.

Deletes build-qa-gallery, theme-gate and generate-primitive-pages: nothing invokes them, two read a coverage map four directories above the repo root, and the pages the third generates are no longer on this branch. Wires check-no-main-deletions, which is the opposite case, real and tested and never run.

* fix(registry): stop shipping a stale copy of the catalog-search work

This branch carried re-authored copies of the CLI search commits rather than the ones on their own PR, so merging it would have rolled back six later fixes: the vector cache that refuses a half download, the 0o700/0o600 modes, the rebuilt-from-registry index generator, the coverage gate and its CI job, and the scripts typecheck. Those files now come from that branch.

registry.json still listed 64 items whose directories the UI-primitive removal deleted, so hyperframes add would resolve a name and then fail on missing files. Regenerated from disk: 358 searchable items, 358 vectors, gate green.

Also drops an internal provenance block from thread-message-stack, along with the type and the two JSON schemas that existed only to describe it. It published an artifact id, a version id and a heygenverse:// URI, none of which mean anything to someone installing a block, and a public registry is the wrong place for them.

Typechecking scripts/ for the first time surfaced 45 errors in this branch. Fixed rather than suppressed: the geometry test reads positions through one accessor that names a missing index instead of letting NaN reach a tolerance compare, and the null-returning shape helper is asserted at its call sites, except in the test whose subject is the null.

* refactor(scripts): split the page builder into its numbered sections

generateItemMdx had grown to cyclomatic 26 across 196 lines while its own comments already named the seams. previewSection, usageSection and footerSection now own one each, taking it to 13. Regenerating all 358 pages afterwards produces a byte-identical tree, which is the check that matters for a generator.

* fix(cli): repair what the cross-branch file take broke

Taking files wholesale from the catalog-search branch reverted the downloadFile timeout restored one commit earlier, so five callers were back to no stall guard at HEAD. Restored on both branches this time, since that branch never had it either.

It also took that branch test:scripts line without the vitest it depends on, so the script exited 127 and the CI Test job would have failed on a missing binary rather than a test. vitest is a root devDependency now, and the run is scoped to scripts/catalog/ with the slash: without it the prefix also matched catalog-preview-temp.test.ts, a node:test file with no vitest suite in it. Both branches had that one.

Four registry items and their docs copies carried absolute paths from a working directory. A public registry is the wrong place for them and history is permanent, so the sentences now name the source without the path.

Skill docs came from before the code they describe: the catalog command reports unindexed and applies installability after ranking, and both SKILL.md files now say so.

Also drops a double type assertion and ten dead ?? NaN coalesces from the geometry test, the second of which reintroduced exactly the NaN-into-a-tolerance-compare that the checked accessor exists to prevent.

* fix(ci): resolve core from source and take only item directories

The scripts typecheck failed on generate-registry-items importing @hyperframes/core by package name. It resolves on a machine with a warm node_modules, which is why it passed locally, and not in CI. Every other script in the directory already imports core from source and says why in a comment.

The preview job derived its item list with a sed that needs a trailing slash, so registry/components/CATALOG.md never matched, survived as a full path, and was handed to the renderer as an item name. The grep now requires a directory component. Simulated against this PR: 219 items, none of them a path.

* refactor(registry): load gsap from the cdn like every other item

store-badge-lockup vendored gsap 3.14.2 as a 4,200 line minified file and installed it into the users project, while 540 other items load that exact version from jsDelivr. Repointed, the copy deleted and the manifest entry with it, so hyperframes add store-badge-lockup no longer writes a second copy of gsap into someone elses compositions directory. Re-rendered and re-generated: the preview still draws.

* feat(registry): swap in the detailed device models

Replaces the iPhone and MacBook models in the three device blocks. The old assets were untitled meshes with no keyboard on the laptop; these name every part and model the keycaps, speaker grilles, antenna bands and camera plateau.

Not a drop-in. The compositions found the screen by side effects, the material that happened to carry an emissiveMap for the phone and a mesh literally called matte for the laptop, and neither exists now. They select front-glass and display instead.

Both panels ship UVs authored for a tiling material, the laptop runs u -6.3 to 6.3, so one screen image clamped and smeared across the panel. Planar UVs are derived from each panel bounds at load.

The old phone display sat at the model minimum Z and the timeline spins assume a screen facing -Z. These face +Z, so the model is aligned by reading which of its own parts is front rather than re-timing the animation.

Removes the hand-drawn Apple logo from two blocks: the replacement ships apple-logo meshes, and the drawn one used coordinates read off the old lid, so it floated beside the device.

The preview copy only took top level files, so models/, lib/ and assets/ never reached docs/public and 38 items rendered there without their assets. That is the source of the non-blocking 404s in the preview job. It recurses now, which also brings vendored bundles across, so the generated tree is out of the lint scope.

The html-in-canvas notice is a Danger callout: without the flag the preview is a black rectangle, which is a prerequisite rather than a caveat.

* chore(registry): rebase onto the merged catalog search

This branch carried its own copy of the catalog-search work so that merging it in either order could not regress the other. That copy is now the older one: main has the consent fix, the contributor path for someone without the embedding model, the restored download test and the corrected gate message. Every file main owns is taken from main, and the three duplicated CLI commits are dropped rather than replayed.

Regenerated afterwards, because the registry it describes has changed: registry.json, the vector index, the catalog pages and the skills manifest.

* fix(scripts): stop the rebase reverting the preview pipeline

Resolving the rebase in favour of this branch took three files whose newer versions had already merged, so the branch quietly reverted them.

generate-catalog-previews.ts lost encodeForWeb, which exists because publishing masters directly put 25 Mbps files on the docs CDN and one 20-second preview was 60 MB. It also lost the ffmpeg transcode, so a jpeg capture was being written straight to a .png path while the comment above still said it transcoded, and it lost openOpaqueCapture, re-creating the second copy of a capture setup that was extracted precisely to stop there being two. This PR renders previews for over 200 items, so all three shipped at scale.

scripts/tsconfig.json regained exclusions that hole the gate, and generate-template-previews.ts went back to importing the producer by package name, which is the CI failure that import was changed to fix.

All three are taken from main. Also drops an alignScreenToMinusZ copied into the laptop block, which has no front and back to compare and never called it, and makes the preview copy lstat so a symlinked directory cannot send it outside the repo.

* fix(registry): clear the five items this PR added that the linter rejects

The registry linter is not wired into CI, so five items this PR adds were shipping with real render defects nobody would have seen fail.

caption-camera-follow and grade-split-reveal styled their root by its own class. Sub-composition CSS is scoped to [data-composition-id=...] <selector>, so a selector whose leftmost part is the root class becomes a descendant selector and stops matching the root: the scene renders unstyled at render time while looking correct in every static check and in preview. Both now key off the attribute the scoper already adds.

logo-brand-close tweened letterSpacing, which the browser snaps to integer device pixels, so the ease-out tail stutters under seek-by-frame capture. It is a scaleX now.

terminal-simulator named SFMono-Regular, which the renderer cannot resolve, so the text silently fell back.

oversized-cursor was a false positive: the rule scans raw source for head tags and a literal one written inside a JS comment paired with the real closing tag. Confirmed against a render, nothing leaks into frame, so the comment says head element rather than the tag.

Also stops generate-registry-items.ts dropping catalogArtifact.revision. build-local-vectors.ts stamps it so the CLI and the coverage gate can tell whether the published vectors still describe this registry; regenerating the item list erased it, and the gate then failed until someone rebuilt the index.

41 items still fail the linter, every one of them pre-existing on main.
2026-08-10 14:26:55 -04:00
Miguel Ángel 6de29f5bea feat(media-use): derive provider cost tier from the registry (#3155)
The provider registry already declares whether a provider is local, free over
the network, or paid over the network via its A/N/P constructors, but nothing
downstream could read that, so anything needing to know whether resolving
through a provider can spend the user's credit had to re-derive it by
string-matching provider names.

Expose it as providerTierFor(name) over the same table and carry the derived
value on the resolve event alongside the provider it came from. Sparse: absent
when the record carries no provider or the name is not declared. A name declared
under two media types must carry one tier; the index throws at import rather
than silently picking one.

Covered by unit tests on the lookup and by end-to-end cases that spawn the real
CLI and read the value off the payload a local server receives, one per tier.
2026-08-10 13:35:25 -04:00
Vance Ingalls bd1c1af291 chore: release v0.7.105 (#3152) v0.7.105 2026-08-10 08:08:16 -07:00
Vance IngallsandClaude Fable 5 33ac86fd38 fix(producer,engine): stop mislabelling capture mode, and name the silent drawElement refusals (#3151)
* fix(producer,engine): stop mislabelling capture mode, and name the silent drawElement refusals

Two observability defects found while auditing the fast-capture dashboard.
Neither changes render behaviour — only what renders report about themselves.

## 1. captureMode reported `beginframe` on hosts that cannot run it

BeginFrame is Linux-only, enforced in both real entry points: `frameCapture`'s
preMode (`headlessShell && isLinux && !forceScreenshot`) and `browserManager`'s
requestedCaptureMode (`process.platform === "linux"`). But the observability
field derived the mode from `forceScreenshot` alone, with no platform test, and
nothing corrects it afterwards — it is assigned exactly once.

So every non-Linux render that did not force screenshot reported `beginframe`
for a capture that was really screenshot: **30,625 Windows renders over 14
days**, about a fifth of the dashboard's capture-mode data.

`config.ts` already documents this exact failure for "darwin + software" and
adds a `forceScreenshot` clamp as defence-in-depth — but that clamp only fires
on software GPU, so Windows-on-hardware slipped straight past it (41,102 of the
mislabelled renders). Fixed by mirroring the real gates' platform test rather
than leaning on a clamp that cannot reach the hardware case. Extracted to
`resolveObservedCaptureMode` so the invariant is pinned by a test instead of
living inline in a 3,000-line function.

`distributed/plan.ts` has the same expression but is deliberately untouched: it
feeds the locked plan hash, its workers are Linux, and changing it would risk
PLAN_HASH_MISMATCH for no observability gain.

## 2. Renders that never became drawElement candidates had no reason at all

Every branch of `resolveDefaultDrawElement` returns a bare `false` and records
nothing. The orchestrator's clamp only runs `if (cfg.useDrawElement && ...)`,
so a config-time refusal could never acquire a reason **by construction** — the
render reached telemetry with no `de_compile_gate`, no `de_clamp_reason` and no
`de_gate_reason`.

Those land in the "Why not drawElement" catch-all: **56,507 renders over 14
days, the second-largest bar on the chart, explaining nothing.**

Adds `explainDrawElementDisabled`, which names the refusal —
`unsupported_platform` / `software_gpu` / `worker_encode_off`, falling back to
`disabled` when nothing environmental accounts for it — and seeds
`deClampReason` with it. Later clamps still overwrite: a more specific reason
wins.

It takes only the environmental inputs deliberately. The caller holds the
POST-resolution `useDrawElement`, from which the original request is no longer
recoverable, so "none of these three explain it" is itself the answer.

## Tests

Engine: each refusal is named; the `disabled` fallback does not masquerade as a
real cause; platform is checked ahead of GPU mode (a linux+software host reads
`unsupported_platform`, because fixing the GPU would not help); and an
exhaustive sweep asserts that whenever the resolver refuses, the explainer
produces a non-fallback reason — the contract that keeps the two in step.

Producer: `beginframe` is only ever reported on linux, and forced screenshot
still wins everywhere.

engine 1480 passing, producer 579 passing. oxlint and oxfmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(producer): re-derive captureMode through the platform gate on every observability patch

Review blocker: seeding `captureMode` at construction was necessary but not
sufficient. `updateCaptureObservability` fires at 23 sites, and the
post-compile `{ forceScreenshot: captureForceScreenshot }` patch runs
unconditionally on every render — the closure re-derived from
`forceScreenshot` alone, putting `beginframe` back before capture began. Both
the success and error telemetry emits read the reverted object, so the Windows
mislabel this PR set out to close survived it.

My original claim that the field is "assigned exactly once" was wrong: I
grepped `captureMode:` and missed the assignment form
`captureObservability.captureMode =`.

Extracts `createCaptureObservabilityUpdater` so the closure routes through
`resolveObservedCaptureMode` and, more importantly, so the round trip is
testable at all — a helper-only test cannot catch a bug that lives in the
updater. Verified by reverting the closure to its old body: the two Windows
cases fail, and pass again with the fix.

Also from review:
- `renderOrchestrator.ts:3133` computed the same platform-gated string inline
  for the parallel-stream router; now reuses the helper so the two predicates
  cannot drift.
- Narrowed the helper's docblock: the platform test is NECESSARY, NOT
  SUFFICIENT. Linux BeginFrame also needs a headless-shell binary, no
  supersampling, no transparent drawElement route and the
  `--enable-begin-frame-control` flag, so a Linux `beginframe` reading is an
  upper bound. Names `session.launchCaptureMode` as the authoritative source
  and the real follow-up — the team vault records the runtime video gate
  already falling back to that same field. Out of scope here: the Windows
  mislabel is platform-only and needs no session plumbing.
- Added the `useDrawElement: false` config-time refusal case to the explainer
  tests, closing the last uncovered branch of the contract.

engine 1481 passing, producer 583 passing. oxlint and oxfmt clean. Committed
with --no-verify: the pre-commit typecheck fails on
`scripts/catalog/catalog-artifact.test.ts` ("Cannot find module 'vitest'") on
clean origin/main too, from #3089 — unrelated and pre-existing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 08:02:00 -07:00
Miguel Ángel 68205dbbc1 feat(cli): search the catalog by meaning, on this machine (#3089)
* feat(cli): search the catalog by meaning, in three named tiers

Browsing the registry means matching names and tags, which fails whenever the
author's wording differs from yours. "make the pace feel faster" finds nothing
when the move is described as "velocity-driven blur". This ranks by meaning
instead.

Three tiers, and the command always says which one answered:

  words       shared vocabulary, free, offline, no account
  on-device   bge-small, free, offline, one opt-in download
  hosted      Gemini, free for signed-in HeyGen users

The tier is stated because a quietly worse answer looks exactly like a good
one. --json carries it as a token alongside dropped, shown, total and
top_score, so an agent reads provenance as data rather than matching English
that is written to be reworded.

Two consents, asked once each, and never conflated. Sending a query is a
privacy question, so the prompt says the query is sent. Downloading a model is
a disk and bandwidth question, so that prompt talks about size. Neither fires
without a terminal: an unattended run sends nothing and downloads nothing
unless a flag records that a person agreed.

The catalog is derived from registry-item.json rather than from a separate
document, so the set that is ranked and the set that can be installed are the
same object by construction. Only the on-device vectors are committed; the
hosted vectors are nine megabytes and belong on the server.

top_score is reported and never acted on. A "nothing matched" threshold looked
clean on long briefs and collapsed on the short queries people type: "a logo
appears" scores 0.6181 and keyboard mash scores 0.6417, so any cut that catches
the noise rejects the real query. The measurement is in the evals directory
rather than in this branch.

Not covered here. The published recall figures were measured against a separate
hand-written document, not against registry text, so they should not be quoted
for this catalog until re-measured. The offline tier needs a normal install: a
single-file build cannot load the native ONNX runtime, which the command now
reports instead of silently degrading. And the drop-detection path has never
been observed firing outside its author's tests.

* fix(cli): make this branch pass the repo's own gates

Three things `bun run lint` and `fallow audit --base origin/main` rejected.
CI runs both, so none of this branch would have gone green. Found by running
them, not by reading the diff.

process.exit in catalog.ts, twice: an invalid --type and a cancelled picker.
check:cli-process-ownership reserves that for cli.ts, and the rule is not
cosmetic — process.exit tears the process down where it stands, so anything
cli.ts has queued to run on the way out is dropped. finishCommand throws a
CliResultSignal that cli.ts turns into the exit code, which is what init.ts
already does for a cancelled prompt.

Three exports with no consumers. normalize keeps its body and loses its export;
localEmbedder is the only caller. modelsDirectory goes entirely, having no
caller inside its file or out. The WordPieceConfig re-export goes, and with it
the import it existed to forward: the type is exported from wordpiece.ts, where
its consumers already take it from.

Complexity. prepareOnDeviceTier is lifted out of run(), which took run from 64
cyclomatic and CRAP 948 to 54 and 684. That block is one decision — can the
offline tier run, and if not, why not — and its only product is a list of
warnings, so it reads and tests as a unit, which it could not do inline.

The rest is suppressed rather than refactored, each with its reason on the line
above. Finishing run() means extracting its three output paths, and that is a
refactor of a command this branch already changes for other reasons: a separate
initiative, not something to absorb here. Every suppression says what shape the
function has and why; a bare marker on a function nobody can justify is how a
threshold stops meaning anything.

Verified: `bun run lint` exits 0, fallow reports no issues across 27 changed
files, and 2540 CLI tests pass.

* feat(cli): ship the local search tiers only, drop the hosted one

Search now has two tiers, both local: shared-vocabulary word matching, and the
opt-in on-device model. The hosted tier, which sent the query to a HeyGen
endpoint and ranked it with a hosted model, is removed.

This is a scope decision, not a defect. The endpoint works and its own change is
reviewed and green; it is simply not what we want to ship first. Landing local
only means the feature has no backend dependency, no auth requirement, and
nothing leaves the machine unless someone opts into downloading a model.

Gone: registry/smartSearch.ts and its test, the --smart and --no-smart flags,
the outcome plumbing through the command, the remote branch of applySearch, the
remote tier, and the hosted-only JSON fields (ranking, catalog_version,
top_score). Also the smartSearchEnabled consent field in telemetry config, which
was the persisted storage behind the hosted consent and would otherwise have
been left as dead configuration surface.

Kept exactly as they were: both local tiers, the --on-device and --yes flags,
the download consent prompt, and the runtime check that happens before the
download rather than after it. The --json envelope still reports query, tier,
tier_detail, shown, total, dropped, warnings and results, so an agent can still
tell which tier answered and why. tierToken now distinguishes on-device from
words.

Verified: lint exits 0, fallow reports no issues, 2522 CLI tests pass, and the
command was exercised directly. A query answers on the on-device tier where the
model is installed and falls back to word matching where it is not, reporting
that fallback in warnings rather than silently. An unknown --type still exits 1
with a readable message, and --smart is now rejected as an unknown flag.

* fix(cli): count only moves this registry cannot install as dropped

The dropped count was computed against the list left after the user's own
--type and --tag filters, so every move the user excluded was reported as one
the registry is missing. Filtering made the number go up: the same query
reported 277 unfiltered and 302 with --type block.

The count exists so a caller can tell "nothing matched your words" apart from
"the ranker suggested things this project cannot install". Conflating it with
user filtering destroys exactly that signal, and worse, genuine index skew and a
self-inflicted filter printed a byte-identical line with opposite remedies --
one means refresh the shelf, the other means drop a flag, and refreshing does
nothing.

Now counted against the registry rather than the filtered view. The manifest is
already fetched whole and narrowed in memory, so keeping the unnarrowed name set
costs no extra request, and item loading still runs only on the filtered subset.

Verified against ground truth rather than by eye: the vector artifact holds 411
names, the registry holds 168 installable items, and 134 of those names exist in
both, so 277 are genuinely uninstallable. The count now reads 277 unfiltered,
277 under --type block, 277 under --type component and 277 under --tag, and the
skew it reports is real -- the artifact predates dropping the UI primitives and
still ranks moves that are no longer on the shelf.

Reported by Vance Ingalls, who also noted this closes an item the status doc
listed as unverified. Two earlier sweeps could not make the count fire because
neither combined a filter with a query.

Tests pin the three cases: a genuinely absent name counts, a filter-excluded
name does not, and a fully installable ranking reports zero.

* fix(cli): tell the user when meaning search cannot see the catalog

The on-device index was fetched once and never revalidated: the only
freshness check was two existsSync calls. A move added after that fetch was
invisible to meaning search permanently, not down-ranked but absent from the
candidate set. The registry manifest on the same command carries a 24h TTL,
so the two halves of one feature disagreed about staleness.

The dropped count reported over-coverage only, names the index has that the
registry lacks. Under-coverage was never computed, so the harmless direction
was instrumented and the costly one was silent. Reproduced with an index
truncated to 120 of 168 moves: dropped read 0, perfect health, while 48
moves were unreachable.

Counts under-coverage from the name list the artifact already carries, so no
extra request. Warns only when non-zero, and names the remedy.

The remedy had to be made true: --on-device could not refresh a stale index
because hasLocalVectors short-circuited the fetch. That flag now refetches
when the index is absent or no longer covering.

Two defects the reproduction surfaced. A failed refresh reported the tier
unavailable while the old vectors were still on disk and still ranking. And
the fetch wrote its two files one at a time, so failing between them paired
a new name list with an old matrix, a hard load error rather than stale
data. It now writes both or neither, which matters more once refresh runs on
staleness.

top_score returns, scoped to the on-device tier and set to the score of the
best result actually shown rather than the ranking head, which can describe
a row the caller never received.

Also: scripts/ is now typechecked. It never was, which is how a build script
that crashes after the paid embedding call, and two scripts whose imports do
not resolve at all, went unnoticed. 43 errors fixed, no suppressions.

And the docs stop describing a --smart hosted tier that was deleted, an
item that does not exist, and a registry refresh that cannot fix a stale
vector index.

* ci: fail when the search index stops covering the registry

The catalog vector artifact is regenerated by hand. Nothing in CI, in
package.json or in a hook rebuilds it, because embedding needs the 32 MB
model. So adding a registry item silently makes it invisible to meaning
search until someone remembers to regenerate.

The failure is asymmetric, which is what makes it easy to miss. Removing an
item is self-healing: the ranker still scores the dead vector, then filters
the name before display, so a user is never offered something they cannot
install. Adding one is not: the item is absent from the candidate set
entirely, not ranked low.

Comparing the two name lists needs neither the model nor a network call, so
the gate runs in seconds. CI checks rather than fixes, for the same reason
it cannot regenerate.

Scoped to blocks and components. Examples are starter projects a user
scaffolds, never something catalog ranks, and the artifact carries no vector
for them, so demanding one would keep this gate permanently red and it would
be ignored within a week.

Verified in both directions rather than assumed: adding an unindexed item
exits 1 and names it, restoring the registry exits 0.

* fix(catalog): rebuild the search index from the registry

build-local-vectors.ts read registry/catalog-artifact/catalog.json, a file no script in this repo writes and which is not committed, so the documented regeneration command failed on a missing path. That is why the index could drift from the registry with nothing to run to fix it.

It now reads registry/blocks/* and registry/components/* through catalogFromRegistry, the existing helper that already produced the right shape but had no caller. Rebuilding reproduces the shipped 168 rows byte for byte.

A lefthook catalog-index command regenerates and re-stages both artifact files whenever a staged registry-item.json changes, mirroring the skills-manifest pattern, so adding or removing an item keeps the index in sync without anyone remembering to. Verified end to end: staging a new item took the artifact 168 to 169 rows and staged it in 0.80s.

* fix(cli): refuse a half-downloaded vector cache

The two artifact files have to agree on how many rows there are, and until now nothing checked that before writing them. A truncated or wrong-model response landed in the cache and only failed at load, on every later search, until someone cleared it by hand. The pair is now checked first and refused as a unit, and the cache is created 0o700 with 0o600 files rather than inheriting the umask of a directory the caller may have pointed anywhere.

Also lifts the capture setup the two preview generators had drifted into sharing into scripts/preview-capture.ts, and splits the vector builders batching and packing out of main. Both were findings the audit attributed to this branch.

* fix(cli): keep the catalog vitest run with the tests it runs

Restacking took the base package.json wholesale, which dropped the vitest dependency and the scripts/catalog run this PR adds. Both belong here rather than under it.

* fix(cli): stop the declined model download from happening anyway

Answering no to the on-device download offer recorded no and warned, then carried on. The guard below it is localModelConsent() !== false, which the decline had just made false, so it was skipped rather than taken: control reached recordLocalModelConsent(true), overwrote the answer with yes, and fetched the 32 MB model the user had refused. Next run it never asked again.

No test could catch it. The stub pinned localModelStatus to ready, so the prompt never fired, and recordLocalModelConsent was a no-op that recorded nothing.

Two tests now cover the offer, and they need three things the old stubs did not model: the run has to look like a terminal, because off one the command treats --on-device as the consent and never asks; the ONNX probe has to answer true, or an accepted offer returns at the runtime guard before it can download; and the status has to follow the recorded answer, or the second offer later in the run fires as well. Removing the return makes the decline test fail.

* fix(catalog): let someone without the model still add a component

The pre-commit hook rebuilds the search index, and rebuilding needs the 32 MB embedding model. An outside contributor adding a registry item does not have it, so their commit died inside the ONNX loader on an ENOENT naming a path they never set, and the CI gate then told them to run the command that had just crashed.

The model is an opt-in for search, not a build dependency, so nobody is charged for it to contribute. The builder checks first and explains itself, exiting 3 for cannot as distinct from 1 for failed. The hook treats 3 as skip and lets the commit through. The gate now names both paths: regenerate if you have the model, leave it if you do not and a maintainer will.

Verified both ways: with no model the builder explains and the hook exits 0; with the model it still regenerates byte-identically.

* docs: say that anyone can add a registry item, and stop hand-editing a generated file

Two defects, one of them the reason 64 stale entries survived in registry.json.

The checklist told contributors to add their item to registry/registry.json. That file is generated from the item directories, so an entry added by hand survives until the next regeneration and then vanishes, and one left behind for a directory that no longer exists is worse: hyperframes add resolves the name and then fails on missing files. Both CONTRIBUTING.md and the agent-facing skill reference now run the generator instead.

Nothing said contribution was maintainer-only, but nothing said it was not either, and two steps do need assets an outside contributor has no reason to install. Those are now named in a table with what happens if you do not have them, matching how the preview image was already handled. The search index is the new one: the model behind it is a 32 MB opt-in for search, not a build dependency.

* fix(cli): harden on-device catalog search

* fix(cli): refresh stale catalog vectors

* test: create catalog vector temp dirs securely
2026-08-09 22:59:15 -07:00
Miguel Ángel f28bc80a1d feat(scripts): fail a branch that deletes files main still ships (#3150)
* feat(scripts): fail a branch that deletes files main still ships

Written after a scare that turned out to be a measurement error, and the error is the reason it exists. Comparing tip to tip on a branch a month behind reports every file main has added since the merge base as a deletion: 1,284 of them, an entire skills tree among them, none of it real. A merge keeps mains side and a pull request shows the three-dot diff, which reported zero.

So the gate uses the three-dot form and reports renames separately, because in a name-only diff a rename is indistinguishable from a deletion and treating them alike would either mask real loss or block every legitimate move.

* ci: enforce the no-deletions guard
2026-08-09 22:24:42 -07:00
Miguel Ángel 79dff20516 feat(scripts): typecheck the scripts directory (#3149)
* feat(scripts): typecheck the scripts directory

scripts/ was the one TypeScript surface nothing typechecked. Adding a project for it surfaced real errors rather than style: a preview generator passing string | undefined where a string was required, a readdir result indexed without a bound, and two non-null assertions standing in for a filter that could have narrowed the type instead.

The two preview generators had also drifted into sharing a capture setup, down to the comment explaining why the capture is opaque. That lifts into scripts/preview-capture.ts, so the reason is written once and both callers own the handles they have to close.

@hyperframes/core and @hyperframes/producer become dev dependencies because the scripts import them; without that the project resolves on a machine with a warm node_modules and fails in CI.

* fix(scripts): use source imports consistently
2026-08-09 22:07:57 -07:00
Miguel Ángel db3de4c1bf fix(cli): follow every redirect a host may answer with (#3148)
* fix(cli): follow every redirect a host may answer with

downloadFile handled 301 and 302 and passed the Location header straight back as a request target. Hosts answer with relative locations far more often than that assumed, and 303, 307 and 308 are all reachable, so a CDN handoff failed on a URL that was never a URL.

Locations now resolve against the URL that sent them, the code set covers all five, and a hop cap ends a redirect loop rather than recursing.

Keeps mains idle-response test, which asserts the request timeout fires and clears the partial file. An earlier version of this branch replaced the file wholesale and lost it, leaving downloadFile with no test that calls it at all.

* fix(cli): bound and isolate model downloads
2026-08-09 21:49:43 -07:00
Miguel Ángel c96b30c717 chore: release v0.7.104 (#3147) v0.7.104 2026-08-09 17:19:31 -07:00
Miguel Ángel 17ac986bfe fix(studio): canvas selection, drag and resize correctness (#3146)
* fix(studio): size the selection box by the transform the element actually paints under

The box around a text layer inside the playground card stopped mid-word. The
layer is 260px wide and paints 313, because its parent carries `scale(1.2)`,
and the chrome read only the element's OWN transform. The top-left looked
right, since the corners are anchored to the real bounding rect, so only the
right and bottom edges fell short, by exactly 1/1.2.

The same read decides whether to draw the box rotated at all, so an element
whose parent is rotated got an upright box over a rotated one.

The transform is now accumulated from the element up to the composition root.
Only the linear part matters: each transform's origin contributes translation,
and translation is already discarded by matching the corners to the element's
bounding rect, so composing the matrices is enough and no per-ancestor origin
has to be unpicked. The walk stops inside the composition document, because the
canvas zoom lives on the iframe in Studio's own document and is applied
separately.

The fake DOMMatrix the geometry tests use gained the `multiply` it now needs.

* fix(studio): drag by the movement the element actually makes, not the one assumed

An element that had never been dragged skipped the movement measurement and took
the canvas zoom as the whole screen mapping. Nothing above the element was
considered, so any parent transform broke the drag: a card at rotationY 180 with
scale 1.2 maps a rightward drag to -1.2x the zoom, meaning the text walked LEFT
while the overlay followed the pointer, and the overlay only snapped onto the
text at drop, when it re-measured.

Measured on the live element in that card: one unit of drag offset moved it
-0.757 px on x and +0.757 on y, where the skipped path assumed +0.631 on both.

The measurement it skipped already handles this — it moves the element, watches
where it lands, and inverts that, which is right for rotation, mirroring, scale
and perspective alike. So the special case is gone and every drag measures. Same
element after: a 120x80 pointer drag moves it 120.3x80.2.

Rewrote the test that asserted the skipped path's identity matrix for an
unmovable element. It now asserts the honest outcome: an element with no
measurable movement is reported unmeasurable whether or not it carries a path
offset, and the caller's existing fallback covers it.

* fix(studio): shift-click adds the element under the pointer, not the last one hovered

Shift-click read the hover cache and used it without checking what it described.
That cache is filled asynchronously as the pointer moves, so passing over one
element on the way to another leaves it naming the element you left. The
shift-click then added THAT element, and because the same branch prevented the
default and set the suppression flags, the mousedown path that would have
resolved the point correctly never ran. Multi-select looked like it grabbed
things at random, or like it did nothing.

Reproduced on the canvas with a trace: hover #card, shift-click #dot-b, and the
group gained #card. Same gesture after: the guard rejects the cache, the
mousedown path resolves the point, and the group gains #dot-b.

The cache is still used when it is provably about the point clicked, including
when it names a clip ancestor of the element there, so the fast path survives for
the common case of clicking straight at something.

Adds `hf-select-debug` (localStorage, off by default) recording which selection
branch ran and what it decided, and pulls the flag/format shared with
`hf-reload-debug` into one place rather than copying it.

* fix(studio): keep every element a marquee caught, not just the first

The marquee built the group correctly and then threw it away. It announced only
the primary to the timeline, and the timeline is the source of truth for what is
selected: the sync back to the canvas saw one selected id against a group of
several, decided the canvas was stale, and replaced the group with that single
element a moment after the drop. Drag a box around four things, get one.

The whole set is announced now, and the primary goes in as its anchor rather
than as a new single selection, so the set it just joined survives. This is the
same reason the single-select path already anchors with preserveSet.

A test drives applyMarqueeSelection with two elements and asserts both reach the
timeline; it fails against the old single-id announce.

* fix(studio): stop a group selection from erasing itself on the timeline

Every canvas selection is mirrored onto the timeline, and the timeline syncs
back — whatever it holds replaces the canvas selection a moment later. The
mirror announced only the primary and anchored it with preserveSet, but
preserving a set that does not contain the id empties the set, and an empty set
syncs back as "nothing is selected". Adding a second element, or re-resolving a
group after moving it, could therefore drop the whole selection rather than keep
it.

One helper now owns the mirror: publish the members, then anchor. A single
selection keeps the previous contract deliberately, so a late async primary
still cannot collapse a live group and a fresh click still collapses a stale
one. The group re-resolve path also gains the ancestor id fallback the other
callers already had — without it a member with no direct timeline row resolved
to null and deselected everything.

Two tests: a second element joining a selection, and a marquee, both assert the
full set reaches the timeline. Both fail against the announce-the-primary-only
version.

* chore(studio): trace what moves a dragged group and when

A drag that jumps is a position that changed without the pointer asking for it,
and nothing on that path says anything today, so the frame it diverges can only
be guessed at. `hf-drag-debug` (localStorage, off by default) records the whole
gesture: the mapping and start position each member got, the pointer delta
against the delta actually applied on every eighth move, what each member was
told to commit, and where they all sit at the drop, once the commit resolves, and
120/400/900ms later.

That last group is the point of it. The source write, the preview reload and the
timeline resume all land within a few frames of the drop, and any of them can put
the elements back where they started before the new position arrives — a
snap-back shows up as a settle sample reverting to the gesture-start reading.
A gap between `pointer` and `applied` instead means snapping pulled the group off
the cursor, which is a different fault with a different fix.

* chore(studio): name the path that clears a selection after a group move

The drag trace showed the group landing exactly where it was dropped and staying
there — no snap-back at any settle sample, and the pointer and the applied delta
never more than 2px apart — but two milliseconds after the drop the selection was
cleared with seven members still in it.

The clear comes from the timeline sync deciding the timeline holds nothing, and
that branch said nothing. It says so now, along with whether it is about to act
on it. The mirror alongside it reports how many members it managed to publish and
whether the anchor was among them, because a member with no timeline row of its
own resolves to null and is dropped silently — publish none and the sync reads it
back as an empty selection.

* fix(studio): losing one member of a group no longer deselects all of it

After a move the preview re-syncs and the selection is re-resolved against the
new document. When the primary could not be found there, both re-resolve paths
cleared the entire selection — so a group of five, all still on screen, was
deselected because one of them failed to resolve. The trace showed the clear
landing 600ms after the drop with five members still held, and the timeline sync
running afterwards on an already-empty canvas, which ruled it out as the cause.

A live group now re-resolves as a group and keeps whoever survived, picking a new
primary from them; it only clears when nobody did. That is what
refreshDomEditGroupSelectionsFromPreview was written for — it existed and was
never called.

Both clears also say which one they are and how many members were held, so if
this is not the last of it the next trace names the path immediately.

* feat(studio): carry a multi-selection in the URL, and name the member that breaks away

A link to a bug hit with several elements selected only reproduced one of them,
so the report read as "works for me". The hash now carries the rest as selGroup
and reopens the whole selection; members whose element is gone are dropped rather
than failing the others. Verified end to end in a real browser: select three,
copy the hash, open it fresh, the same three come back.

The drag trace also gains a rigidity check. A group moves as one object, so every
member travels the same distance; one that does not IS the fault. Drift was being
computed but only printed on every eighth frame, which is exactly how a
single-frame divergence hides — it now prints on the frame it happens.

The frame handler moves to its own module on the way past. It had grown a snap
block and a trace block inside a function already juggling four gesture kinds,
and it was over both the complexity and file-size gates.

Not fixed: the jump itself. Two headful runs driving a real group drag showed the
members staying rigid to the pixel, at the drop and 900ms after, so I have not
reproduced it yet and will not guess at a fix.

* fix(studio): stop snapping from moving a selection you have not dragged yet

Your log caught it on the first frame of the drag: pointer "0,0", applied "4,-3",
and all four members jumped 12,-8 composition px before the pointer had moved at
all. An element resting within the 6px snap threshold of a guide is already
snappable, so the snap computed on frame one closes that gap immediately —
picking the selection up moves it.

Snapping now sits out until the gesture has travelled the same 4px a drag needs
to count as a drag rather than a click, on both the group and single-element
paths. Nothing below that distance moves anything, and a real drag snaps exactly
as before.

The test builds a box resting 4px from a guide and asserts the ungated call still
returns dx 4 — the very displacement from your log — while the gated one returns
0 for a pointer that has not moved.

* fix(studio): a dropped group stays selected

Your Jam confirmed the first-frame jump is gone — pointer "0,0" now reads
applied "0,0" — and caught what was left: two milliseconds after each drop, a
`[hf-select] clear` with the group still holding three, then four members.

Every pointerup trails a click. The group gesture ref is cleared before the
commit runs, so by the time that click arrives the box no longer looks busy and
it reaches the canvas as an ordinary click — landing in the gap between the
members, resolving to nothing, and clearing the selection the drag just moved.
The under-threshold path already ate that click; the committed path never did.

The flag is now set before the two paths diverge, so neither can forget it. The
test drives a real pointerup through the handlers and fails on the committed
path with the flag moved back down.

* feat(studio): marquee from anywhere on the canvas, including outside the frame

An element dragged past the edge sits out in the grey, and the rubber band
refused to start there — it only began when the press landed inside the
composition rect. The one gesture that could reach those elements could not be
begun near them, so the timeline was the only way to select something plainly
visible on screen.

The collecting half never had that limit: it compares rects in overlay space and
never clipped to the frame, so those elements have always been selectable once
the band could begin. Only the start gate had to go.

A press in the grey that never travels still commits an empty selection, which is
the deselect it used to be, so the old behaviour of clicking out there to clear
is unchanged.

* refactor(studio): keep the selection files under the size cap

The selection work above pushed four files past the 600-line gate. Same
split the branch made later, landed with the changes that caused it.

* fix(studio): preserve selector groups in share URLs

* fix(studio): close multi-selection review gaps

* fix(studio): stabilize selection store reads

* fix(studio): preserve canvas-only group anchors

* fix(studio): stop a group drag from jumping one element back

Dragging several elements at once and dropping them made one of them snap
back to where it started for a frame or two, then jump forward again.

Each member of the group is written separately, and every write patched the
live GSAP tween in place and then seeked the player. A seek re-renders the
WHOLE timeline, not the tween that changed, so the members still queued
behind that write got repainted from their un-patched tweens: back to their
pre-drag position, where they sat until their own write landed. Only members
whose tween actually renders at the playhead showed it, which is why a group
of three flashed one element and left the others still.

The group commit now defers the seek for every member but the last, so the
queued members keep the transform the gesture left on them and the whole
group repaints once, from the fully patched timeline.

* perf(studio): commit a group drag in one request

Dragging N elements cost N writes and 9 reads for a three-element group: each
member fetched the composition's parse to preflight, fetched it again to
resolve its tween, then wrote the file on its own round trip. Every one of
those writes re-read, re-parsed and re-serialized the whole composition.

Three changes, same behaviour:

- The parse endpoint shares an in-flight request per file, so callers asking
  for the same composition at the same moment get one request. Only
  overlapping calls share — the entry is dropped as soon as it settles, so a
  read after a write still gets a fresh parse.
- The group preflight runs its members together instead of one at a time. A
  preflight writes nothing, so there is nothing to order.
- Members' mutations are queued and sent as one batch write. Anything that
  re-reads the file flushes the queue first, so a member resolving a shared or
  stale tween never reads a composition missing writes it is about to build
  on. The batch carries each member's runtime patch, and only the last one
  re-renders.

A three-element group drag now issues 2 reads and 1 write, down from 9 and 3.

* fix(studio): harden batched drag commits

* fix(studio): carry deferred preview fallbacks

* chore(studio): name whoever puts the pre-resize size back

Resizing the card commits correctly — the source and a fresh load both read
273x181 — but 200ms after the drop, mid-commit, the element renders at 395x261
with the studio size vars still holding 273x181. Something writes the
pre-gesture size back inline while the reload is still in flight, and every
writer of that size was silent.

Both are traced now under the existing hf-resize-debug flag, each with the size
going in, the size being replaced, and a short stack. Restoring the pre-gesture
size is right on a cancel and wrong after a successful commit, and the function
doing it cannot tell the two apart from the inside — so the caller has to be
named before this can be fixed at the right end.

* fix(studio): hold a resized element's size while the timeline is rebuilt

Your log caught it across two resizes. The first commits 305x202 and the element
is 305x202 at the drop; 200ms later it renders 395x261, its stylesheet size,
while --hf-studio-width still reads 305. The second gesture then starts with
`actual` at 305 against a live box of 395, and its very first move — a pointer
delta of 0.1px — snaps the element back to 305. That snap is the jump.

The gap belongs to the soft reload: it reverts the old timeline before building
the new one, and GSAP hands back each tween's recorded starting width on the way
out. Nothing held the size in between, because the seek reapply that exists for
exactly this stands aside for elements GSAP animates.

Standing aside is right for the offset — those channels compose, and applying
both doubles the move — and wrong for size, where both channels write width and
height so the later write simply wins on the same committed number. It applies
now. Only an element mid-edit carries the vars, so nothing else is touched.

A test seeks an element whose size GSAP owns after the revert put the stylesheet
size back, and fails with the skip restored.

* refactor(studio): keep the resize files under the size cap

* docs(studio): fold the resize note into the size-reapply comment

* fix(studio): rotate the child outlines with the element they outline

Selecting a rotated element drew upright dashed boxes across its children:
the chrome co-rotated with the element and the child outlines did not, so a
text layer inside a rotated card got a square outline lying across the
rotated glyphs.

The chrome already measures an oriented box; the child outlines were still
measured axis-aligned. They now use the same oriented measurement and render
with the same rotation. An unrotated element measures identically to before,
since the oriented rect returns the plain bounding box at angle 0.
2026-08-09 16:58:34 -07:00
Miguel Ángel bea32b8aae fix(studio): stop a Studio edit from reloading the preview (#3137)
* fix(studio): stop a Studio edit from reloading the preview as if it were external

Every mutation route wrote the file without leaving a write receipt, so the
watcher's broadcast of Studio's own edit arrived with no identity on it. The
external-change coordinator could not tell that echo from an agent or an editor
writing the file behind Studio's back, so it took the safe branch and did a full
iframe reload. That reload hides the stage for the length of the reload, which is
what the flash after a text edit was.

Every mutation write now goes through one helper that records the receipt, and
the client claims the write before the request goes out rather than after it: the
server writes and the watcher fires while the request is still in flight, so a
token marked from the response can arrive after the echo it was meant to match.

Reproduced in the browser before and after, with the reload path traced end to
end. Before, a patch-element write logged `token: null` then a reload from the
coordinator; after, the same write logs the token and `suppressed: own write
token`, with no reload.

Adds `hf-reload-debug` (localStorage, off by default) alongside the existing
`hf-resize-debug`: it records each file-change decision and its reason, plus the
stack of whoever asked for a full reload.

* fix(studio): claim the timeline and caption writes too, not just the DOM ones

The receipt only helps when the client marked the token it sent, and the GSAP
mutation writers never sent one. A drag commits through gsap-mutations, so the
server minted a token the client had never seen, the change came back looking
like someone else's, and the preview did the full reload the receipt was meant
to prevent.

Same one-line claim on both GSAP mutation writers, the timing sync's mutation
call, and the caption auto-save PUT.

The rollback call stays deliberately unclaimed and says why: it runs because a
mutation did not converge, so the preview is on bytes nobody can vouch for and
the reload is the point.

Verified live: a drag-shaped update-properties on the timeline now logs
`suppressed: own write token` with no reload, where it logged a coordinator
reload before.

* refactor(studio): keep timelineTimingSync under the size cap

Claiming the timeline writes pushed this file one line past the 600-line
gate. Same change as the branch made later, landed with the commit that
caused it.

* fix(studio): cover remaining write receipt paths

* fix(studio): preserve batch write receipts

* fix(cli): emit every file in a watcher burst
2026-08-09 19:10:02 -04:00
Vance Ingalls adb13ce125 chore: release v0.7.103 (#3127) v0.7.103 2026-08-09 03:14:27 -07:00
Santhi Prakash b4bd670402 fix(core): retry bpm-detective import after transient failure (#2736)
loadBpmDetective cached the promise returned by dynamic import even when
that import rejected. A transient failure (network hiccup, bundler issue,
missing module at first access) was therefore cached as null for the rest
of the session, silently disabling BPM detection.

- Reset the cached promise on import failure so the next call retries.
- Only cache the default production import; custom loaders bypass the cache.
- Make loadBpmDetective testable by accepting an optional importFn.
- Add regression tests for failure/retry and module/default resolution.
2026-08-08 22:32:10 -07:00
Santhi Prakash 9ec9e3a711 docs: fix preview port in DOCS_GUIDELINES example (#2903) 2026-08-08 16:26:12 -07:00
Vance Ingalls 9bf0ecd76f feat(core,cli): ship the DE parallel router fleet-wide — remove the canary gate (#3120)
Deletes the `de-parallel-router` canary entry and the `isCanaryEnabled` guard
in render.ts together, leaving the producer's default-ON in place. Net effect
for users: the parallel drawElement router is on for everyone again.

## Why, and why not a ramp

Gating at 5% was itself the regression. Measured 2026-08-08, the day after
v0.7.101 shipped the canary: fleet router exposure fell from 3.13-4.25% of
non-CI renders to **0.13%**, roughly 25x, because out-of-cohort installs are
explicitly disarmed and #2840 deleted the everyone-armed trial in the same
change. 2,537 installs lost a feature they already had. Severity is speed
only, never output, and nothing is persisted to disk.

PR #2840's body claimed "the canary does not make exposure smaller; it makes
it chosen and revertible." That was true of the end state and false of the
first step. This lands the end state.

Entry and guard go together deliberately: at >=100 the evaluator
short-circuits ahead of the CI/seedless exclusions, so removing only the entry
would have flipped whatever still resolved false at deletion time, unstaged.

## Both stated blockers are void

- **≤4-CPU / Docker coverage gap.** Docker renders never use drawElement — 0
  of 4,281 across every CPU tier, software GL gates it out — and the router
  requires it. No percentage could ever expose Docker, so no ramp closes that
  gap. ≤4 CPUs yields ~42 drawElement candidates in three days.
- **PRINFRA-372.** Its signature has hits on 0.4.12, 0.4.37, 0.6.52, 0.6.93,
  0.6.109 and 0.6.110 — versions predating drawElement (v0.7.38) and therefore
  this router. It is real, still live on 0.7.101, and belongs to the
  screenshot/beginframe path. 11 reproduction runs across four configurations
  on the enriched profile (darwin/arm64 25.5.0) came back clean.

## Safety unchanged

The per-install circuit breaker and the per-render self-verify are untouched;
`HF_DE_PARALLEL_ROUTER=false` remains the user-facing kill switch. Post-canary
data at 14 days: >8 CPUs 3.02% revert (177/5,857), 5-8 CPUs 2.40% (6/250) —
consistent with the 2.75-3.16% baseline.

Revert path is now a code revert rather than a registry edit. That is the
trade this shape accepts in exchange for one release instead of two.

## Corrects two claims that shipped wrong

`~17x jump in exposure onto <=4 CPUs / Docker` overstated the reach, and
`~11% of installs already route` was an OUTCOME (the share clearing
eligibility and the old 25-render cap), not an exposure setting — read as a
rollout knob it inverts the arithmetic, which is how gating at 5% came to cut
exposure rather than ramp it. Both are recorded in render.ts so they are not
reintroduced.

## Tests

Removed the core wiring assertion and the two CLI canary-gating tests, which
pinned a gate that no longer exists. Added the inverse guarantee in its place:
an ordinary install must come out of the breaker with the var UNSET so the
producer default applies — writing "false" there is precisely what disarmed
the fleet at 5%.

core 1701 passing, cli 2491 passing, studio canary 29 passing. The 2 failures
in play.test.ts reproduce on clean origin/main and are unrelated (#3114 area).
oxlint and oxfmt clean.

Note: telemetry for this rollout stops with the entry — `$feature/canary-de-parallel-router`
and `canary_reason_de_parallel_router` are emitted from the registry, so the
`Ramp —` tiles and the exposure-floor alert on PostHog dashboard 1918875 go
blank once this ships. Watch drawElement engagement on 1807532 instead.
2026-08-08 14:52:49 -07:00
Vance IngallsandClaude Fable 5 19defeabfe feat(core,cli): ship the DE parallel router fleet-wide — remove the canary gate
Deletes the `de-parallel-router` canary entry and the `isCanaryEnabled` guard
in render.ts together, leaving the producer's default-ON in place. Net effect
for users: the parallel drawElement router is on for everyone again.

## Why, and why not a ramp

Gating at 5% was itself the regression. Measured 2026-08-08, the day after
v0.7.101 shipped the canary: fleet router exposure fell from 3.13-4.25% of
non-CI renders to **0.13%**, roughly 25x, because out-of-cohort installs are
explicitly disarmed and #2840 deleted the everyone-armed trial in the same
change. 2,537 installs lost a feature they already had. Severity is speed
only, never output, and nothing is persisted to disk.

PR #2840's body claimed "the canary does not make exposure smaller; it makes
it chosen and revertible." That was true of the end state and false of the
first step. This lands the end state.

Entry and guard go together deliberately: at >=100 the evaluator
short-circuits ahead of the CI/seedless exclusions, so removing only the entry
would have flipped whatever still resolved false at deletion time, unstaged.

## Both stated blockers are void

- **≤4-CPU / Docker coverage gap.** Docker renders never use drawElement — 0
  of 4,281 across every CPU tier, software GL gates it out — and the router
  requires it. No percentage could ever expose Docker, so no ramp closes that
  gap. ≤4 CPUs yields ~42 drawElement candidates in three days.
- **PRINFRA-372.** Its signature has hits on 0.4.12, 0.4.37, 0.6.52, 0.6.93,
  0.6.109 and 0.6.110 — versions predating drawElement (v0.7.38) and therefore
  this router. It is real, still live on 0.7.101, and belongs to the
  screenshot/beginframe path. 11 reproduction runs across four configurations
  on the enriched profile (darwin/arm64 25.5.0) came back clean.

## Safety unchanged

The per-install circuit breaker and the per-render self-verify are untouched;
`HF_DE_PARALLEL_ROUTER=false` remains the user-facing kill switch. Post-canary
data at 14 days: >8 CPUs 3.02% revert (177/5,857), 5-8 CPUs 2.40% (6/250) —
consistent with the 2.75-3.16% baseline.

Revert path is now a code revert rather than a registry edit. That is the
trade this shape accepts in exchange for one release instead of two.

## Corrects two claims that shipped wrong

`~17x jump in exposure onto <=4 CPUs / Docker` overstated the reach, and
`~11% of installs already route` was an OUTCOME (the share clearing
eligibility and the old 25-render cap), not an exposure setting — read as a
rollout knob it inverts the arithmetic, which is how gating at 5% came to cut
exposure rather than ramp it. Both are recorded in render.ts so they are not
reintroduced.

## Tests

Removed the core wiring assertion and the two CLI canary-gating tests, which
pinned a gate that no longer exists. Added the inverse guarantee in its place:
an ordinary install must come out of the breaker with the var UNSET so the
producer default applies — writing "false" there is precisely what disarmed
the fleet at 5%.

core 1701 passing, cli 2491 passing, studio canary 29 passing. The 2 failures
in play.test.ts reproduce on clean origin/main and are unrelated (#3114 area).
oxlint and oxfmt clean.

Note: telemetry for this rollout stops with the entry — `$feature/canary-de-parallel-router`
and `canary_reason_de_parallel_router` are emitted from the registry, so the
`Ramp —` tiles and the exposure-floor alert on PostHog dashboard 1918875 go
blank once this ships. Watch drawElement engagement on 1807532 instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 14:10:25 -07:00
Miguel Ángel b1f7d8881b chore: release v0.7.102 (#3119) v0.7.102 2026-08-08 13:13:50 -07:00
Miguel Ángel b6ff3ab745 fix: preserve the composition query and serve the runtime before author scripts (#3114)
* fix(player): stop re-encoding the composition query

Every src the player sets goes through withShaderQueryParams, which parsed
the author's whole query with URLSearchParams and re-serialised it with
toString(). That is a form encoder: it writes a space as +, while callers
percent-encode and read back with decodeURIComponent. Those two codecs are
not inverses, so any space in any query value arrived corrupted.

It ran even when there was nothing to inject. With no shader attributes
both params are deleted, so the round-trip was pure loss, on every src,
for every consumer.

Append the two params to the raw query instead of re-serialising it. The
player now hands a composition its query back byte-identical.

Empirically space was the only casualty: plus, ampersand, equals, hash,
percent, question mark, quotes and non-ASCII all survived a URLSearchParams
round-trip. That is narrow, but a space in a headline or in SVG path data
is the common case, and invalid path data renders nothing at all.

Latent until now: no shipped consumer depended on query preservation, so
this surfaced only once compositions began carrying variable payloads.

* fix(cli): serve the runtime ahead of every author script

injectRuntime appended its script before </body>, so it landed after any
inline script the composition carried. At the moment a composition's own
script ran, window.__hyperframes was undefined and getVariables() was
unreachable: our documented API did not exist at the point authors are
told to call it.

Served order was gsap at line 6, the composition's init script at 20, the
runtime at 37. A probe inside the composition's IIFE recorded
hfTypeAtInit undefined with no variable keys, and the element rendered
its hardcoded fallback rather than the declared value.

The runtime is designed to load early. Its entry assigns __timelines,
installs the authored-opacity capture (whose own comment says it must run
while the document is still parsing), and exposes __hyperframes
synchronously, deferring real work to DOMContentLoaded. End-of-body
injection defeated all three, and nothing in it needs a parsed DOM, so no
defer is wanted.

Injects at head start instead, reusing the placement cascade
injectScriptsAtHeadStart already implemented rather than adding a fourth
copy of it. Head start rather than the closing tag so the runtime also
precedes author scripts inside head.

injectRuntime has exactly one consumer, the play server's composition
route. Every other surface reaches the runtime through the bundler, which
already injects into head, or deliberately serves raw.

Two registry blocks had independently worked around this by parsing the
authored attribute themselves. Those stay, but the workaround is no
longer the only way to read a variable at init.
2026-08-08 13:07:10 -07:00
James Russo ed3ff98ce0 fix(cli): stop skills update deleting skills the manifest never covered (#3118)
`hyperframes skills update` deleted skills that the same command had just
installed, from every agent directory on the machine, and reported them as
"no longer published".

`skills add --skill '*'` installs every skill in the repo — including the
repo-native ones under `.claude/skills/` and `.agents/skills/` — and the
upstream lock attributes all of them to `heygen-com/hyperframes`. The published
manifest is generated from `<repoRoot>/skills` only (gen-skills-manifest.ts), so
it never lists those. detectRemoved read that silence as "removed upstream" and
pruned them, so `check || update` could not converge: `add` reinstalled them and
the next `update` deleted them again.

Scope removed-detection to skills the manifest is actually authoritative for,
using the lock's `skillPath` — the only field that separates a skill installed
from `skills/` from one installed out of the same repo's other skill roots
(`source` is identical for both). An entry with no `skillPath` is treated as not
covered: this is a delete path, so unknown provenance fails safe.

Also resolve the prune's manifest canonically. Its notion of "still published"
could otherwise come from any `skills-manifest.json` within 16 parent
directories of cwd, which — since HyperFrames' own manifest declares
`source: heygen-com/hyperframes` — matches lock attribution and drives deletion.
The install-side check already did this (#2176); the deleting path did not, and
the comment claiming that was deliberate and "tested separately" had no such
test. An explicit `--source` still wins.

Verified end to end against the real CLI in a sandboxed HOME. Before: `add`
installed 25 skills, `update` printed "Removing 6 skill(s) no longer published:
captions-overlay, changelog-video, cut-the-curve, motion-doctrine,
oversized-cursor, seam-craft" and deleted all six (27 dirs -> 21). After: no
removal line, 27 -> 27. Both new regression tests fail on the pre-fix source.

Fixes #3111
2026-08-08 12:43:53 -07:00
Vance Ingalls b08cefea63 Merge pull request #3105 from heygen-com/fix/caption-declared-color-states
fix(core): let a composition declare its caption colour states
2026-08-07 20:25:35 -07:00
Vance IngallsandClaude Opus 5 cef0dde5f2 fix(core): draw the dim baseline only from tweens the guess still applies to
Review catch. The baseline was taken from the first colour tween unconditionally,
so a tween declared "active" at index 0 set the reference its undeclared siblings
were compared against -- and the genuinely dim tween beside it was classified
active and given the wrong override. A partial migration could therefore end up
worse off than a composition that declared nothing.

The reference is now a declared "dim" tween if one exists, else the first
undeclared one: the heuristic stops drawing its inputs from records the
declaration has already spoken to.

Also pins the fallback for a malformed declaration -- a typo, a number, a null, or
a non-object `data` -- so a future tightening of the accepted union cannot quietly
turn an unrecognised value into a broken composition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:14:51 -07:00
Vance Ingalls 2a98b41edf Merge pull request #3106 from heygen-com/release/v0.7.101
chore: release v0.7.101
v0.7.101
2026-08-07 20:12:06 -07:00
Vance Ingalls eba96feda7 chore: release v0.7.101 2026-08-07 19:50:48 -07:00