Commit Graph
60 Commits
Author SHA1 Message Date
Miguel Ángel 7e96e60fe2 ci: bound the ffmpeg apt fetch so a stalled mirror costs a retry, not the job (#3356)
* ci: bound the ffmpeg apt fetch so a stalled mirror costs a retry, not the job

Hosted runners intermittently stall on an apt mirror, and an unbounded
apt-get inherits the whole job budget. The producer integration lane normally
finishes in ~11 minutes against a 20 minute cap; on a stalled fetch it ran to
the cap and failed. Same step, same shape, reproduces on main's tip — it is not
specific to any one PR.

The cost is not one red check. On the run that prompted this, four went red off
that single step: the two jobs that install ffmpeg, plus a Test gate and a
preview-regression gate that both fail closed when their dependency does not
succeed. So a mirror stall reads as a producer defect and a preview defect.

Each attempt is now bounded and retried three times, and the five workflows
that installed ffmpeg share one action instead of five copies of the command.
Deliberately still apt: caching the binary would strip it from the shared
libraries it links against, and switching to a static build would change the
ffmpeg under the producer's output comparisons. Neither belongs in a fix for a
network stall.

* ci: drop the stray version echo left in the player-perf ffmpeg step

Converting the step to the shared action left the trailing `ffmpeg -version`
line behind, and YAML folded it into the `uses:` value — so the runner looked
for an action at a path with the command appended and failed all four perf
shards.

It parsed cleanly, which is why validating with a YAML load did not catch it:
`uses: ./path\n  ffmpeg -version` is a legal folded scalar. The check that
does catch it asserts every local `uses:` resolves to a directory containing
an action file, which is now what I ran. The action prints the version itself.

* ci: bound the ffmpeg fetch at the connection, not with a wall-clock kill

The first version wrapped apt in `timeout` and retried. A passing run showed
why that is the wrong shape: the mirror is slow rather than hung — the install
spent ~15 minutes pulling packages from azure.archive.ubuntu.com and finished
successfully. Killing it at 300s discarded a download that was making progress
and started over, so the retry turned a slow mirror into a slower one, and the
worst case of three attempts exceeded the job's own 20 minute cap.

Bound the connection instead. Acquire::Retries re-fetches the one package whose
connection stalled while keeping everything already downloaded, and
Acquire::http::Timeout caps how long any single connection may sit idle. That
addresses the stall the original report described without punishing the slow
case that is far more common.
2026-08-19 18:39:05 -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 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 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 0bda6b55b8 feat(cli): track which registry items add installs (#3099)
* feat(cli): track which registry items `add` installs

`cli_command` records that `add` ran and nothing about what it installed, and
the registry is served from raw.githubusercontent.com, which gives no per-item
counter either — so there is no way to tell which block or component people
actually pull, and no way to know what is worth building more of.

Emit one `registry_item_added` event per item written into a project, from
`runAdd` after the install succeeds. That is the single choke point: the bulk
`add <tag>` path re-enters it per item, and a failed or compatibility-refused
install throws before it, so a refused install is never counted as a download.

`requested` separates the item the user named from the transitive
`registryDependencies` pulled in behind it; without it a popular dependency
outranks everything that depends on it.

Item names are public registry identifiers, never user content or project data,
and the event goes through `trackEvent` — an install that opted out via
`hyperframes telemetry disable`, `HYPERFRAMES_NO_TELEMETRY` or `DO_NOT_TRACK`
sends nothing.

* test(cli): cover `add` telemetry end to end against the built CLI

The unit tests assert the emit seam and nothing past it. `shouldTrack()`
short-circuits whenever `isDevMode()` is true, and that is true for any `.ts`
entry, so under vitest a real event and no event are indistinguishable and the
transport is never exercised at all.

Drive the built CLI instead and assert on the HTTP body it actually produces:
one event per installed item, the dependency reported with `requested: false`,
an opted-out install sending no request at all (not merely one without this
event), and a refused install counting nothing.

Two fixtures, because neither case is reachable through the real registry. The
registry origin is a first-class project setting, so a local one supplies the
`registryDependencies` edge that no shipped catalog item declares today; and
`globalThis.fetch` is wrapped to capture the batch rather than send it. The
faked 200 is load-bearing: only a failed flush leaves events queued, and only a
non-empty queue spawns the detached `flushSync` child that would bypass the
hook and reach production analytics.

Verified the check can fail — forcing `requested: true` for every item turns it
red on exactly the dependency assertion.
2026-08-07 16:00:23 -07:00
Miguel Ángel ebdd1893c4 fix(studio): reconcile external edits before reload (#2993)
* fix(studio): reconcile external edits before reload

* fix(ci): retry transient workspace installs

Make external reload retry behavior honest and isolate reload listeners.

Remove the dead SDK timestamp parameter.
2026-08-04 23:13:19 +00:00
James Russo 9792c32950 fix(producer): reject asset media type mismatches (#2937)
* fix(producer): reject asset media type mismatches

* fix(engine): document read-only AVIF probe

* fix(engine): bound read-only AVIF brand probe

* fix(producer): make media preflight lifecycle-safe

* fix(producer): reconcile runtime media before preflight

* fix(engine): avoid writable file-open detection

* fix(producer): close runtime media preflight gaps
2026-08-03 18:16:41 -07:00
Miguel Ángel c6925e471a feat(studio): enable timeline virtualization by default (#2926)
* feat(studio): enable timeline virtualization by default

* fix(ci): measure timeline performance in production React
2026-07-31 19:43:47 +02:00
Miguel Ángel 723d3381c4 fix(studio): keep dense keyframes readable (#2925)
* perf(studio): define timeline viewport budgets and fixtures

* test(studio): gate timeline viewport performance in Chromium

* refactor(studio): isolate clip drag lifecycle

* refactor(studio): extract timeline render contracts

* perf(studio): centralize timeline viewport geometry

* perf(studio): follow playhead across virtualized rows

* perf(studio): add timeline clip-window index primitive

* perf(studio): virtualize timeline clip windows

* perf(studio): stop timeline scroll work when row virtualization is off

The row virtualization stack made the timeline publish a viewport snapshot
on every scroll frame and swap `renderClipContent` across every mounted clip
at gesture start and settle. Both are windowing concessions, and neither was
gated on the flag, so the build users actually run paid for them while
mounting all 1,000 clips anyway. Measured on a 3,000-clip project: median
scroll step 16.6ms to 76.9ms, p95 17.9ms to 189.4ms, 40 long tasks to 247.

Gate both on the row virtualization flag. The scroll path now stops at the
door when the flag is off, so `isScrolling` stays false and resize-driven
and programmatic syncs still publish through the immediate path.

The flag moves into its own module: the scroll-viewport hook needs to read
it, and the virtualization hook already imports the viewport snapshot type
back, which would have closed an import cycle.

Also release the perf fixture lease from the fixture rather than from the
test-hook effect. Loading a fixture writes player state, which changed that
effect's dependency identities and tore it down on the next frame, so the
lease was revoked moments after it was taken and live iframe discovery
overwrote the fixture before the gate could measure it.

The e2e gate gains a flag-off arm (`test:timeline-default`, 1,000 elements)
next to the existing flag-on one. It refuses the 50,000-element combination,
verifies from the mounted DOM that the server under test matches the
requested flag, and skips the DOM-size budgets for the unvirtualized build
rather than relaxing them, so a skipped budget never reads as a passed one.

Verified against a live Studio dev server on the fixture project:

  flag off, before: interactionP95 303.1ms, longest task 194ms, 0/5 runs pass
  flag off, after:  interactionP95  33.6ms, longest task   0ms, 5/5 runs pass
  flag on,  after:  interactionP95  33.2ms, 4/5 runs pass, exit 0

The flag-on arm's fourth run reproducibly reports a 55-58ms long task
against a 50ms budget. That is the residual tail of the window swap itself,
tracked separately and not addressed here.

* ci(studio): run the timeline viewport gate on studio changes

The gate has existed since the row virtualization stack landed but nothing
under `.github/` referenced it, so it only ever ran when someone ran it by
hand. That is how the flag-off scroll regression reached eight merged-ready
PRs without anything noticing.

Adds a `studio-timeline-viewport` job that boots two Studio dev servers, one
per flag state, and runs both arms of the gate against them. Two servers are
needed because row virtualization is read from `import.meta.env` at module
load, so one process cannot serve both builds.

Scoped to a new `studio` paths filter rather than the broad `code` one: the
gate only says anything about `packages/studio`, `packages/core` and
`packages/studio-server`.

Adds a `ci` tier. It applies the constrained budgets without any emulation,
because a hosted runner is already slower and noisier than the machine the
strict numbers were recorded on, while the existing `low-resource` tier would
throttle it a further 4x and measure the throttle rather than the build.

The fixture composition is tracked under `tests/e2e/fixtures` but Studio
resolves projects from the gitignored `data/projects`, so the job copies it
into place instead of a project directory being committed.

Both arms run in about 7 seconds each locally, so the job cost is almost
entirely dependency install and the workspace build it shares with
`studio-load-smoke`.

* fix(ci): preserve both timeline gate evidence arms

* ci(studio): report timeline gate arm statuses

* ci(studio): require timeline gate evidence artifacts

* fix(studio): keep dense keyframes readable

* fix(ci): resolve timeline stack audit findings
2026-07-31 18:05:22 +02:00
James 2a284a8e3a fix(gcp): enforce effective BeginFrame capture 2026-07-27 00:02:37 +00:00
James RussoandMiao Yang 696cbdbbd0 chore(skills): package Codex plugin upload (#2668)
* chore(skills): package Codex plugin upload

* chore(skills): harden Codex plugin content

* fix(skills): satisfy plugin quality gates

* fix(skills): address plugin packaging review

* fix(plugin): simplify asset validation

* fix(skills): correct embedded-captions catalog count to 35 after nightcity removal

The nightcity theme removal left SKILL.md claiming 36 identities in
four places, including the frontmatter description the router reads.
The catalog now has 35 entries (10 classic + 25 themed).

---------

Co-authored-by: Miao Yang <miao.yang@heygen.com>
2026-07-22 00:41:40 +08:00
James RussoandJake Moran e96ebd74de feat(skills): add changelog-video skill for repo-native CC + Codex discovery (#2552)
Packages Jake Moran's changelog-video pipeline (v1, validated end-to-end
by Home on the Jun 23-29 range) as a repo-native skill set that Claude
Code (.claude/skills/) and Codex CLI (.agents/skills/) auto-discover the
moment the repo is opened. No install step; run the skill against a
changelog markdown for a given git range and it produces a lint-clean,
seam-gate-green 1080x1080 MP4 (~45-60s, Annie VO, mock-UI visualizations,
caption rail) end-to-end.

Six skills added byte-identical in both mirror dirs:
- changelog-video (pipeline entry point)
- motion-doctrine (carries seam-stamp.mjs + seam-gate.mjs)
- cut-the-curve, captions-overlay, seam-craft, oversized-cursor

Layout:
- .claude/skills/  - Claude Code project-local auto-discover
- .agents/skills/  - Codex CLI project-local auto-discover (verified via
                     Magi's clean-home Codex 0.144.3 repro; NOT .codex/skills/)

Fonts, animated background (12 MB), house BGM (5 MB), lexicon, and
align-captions ship inside the skill dirs. .gitattributes routes only
.claude/skills/**/*.{mp4,mp3} + .agents/skills/**/*.{mp4,mp3} through
LFS — narrowly scoped so unrelated Player, Studio, registry, and
marketplace media stay put. HeyGen CLI auth is the one credential the
skill needs; Node >= 22, ffmpeg, and headless Chrome are documented
alongside in both READMEs.

.gitignore: rewrites .claude/ and .agents/ blocks to keep agent-installed
skill hygiene while re-including the six repo-native skill dirs plus
README.md.

CI:
- Extends changes.skills filter to match .claude/skills/**,
  .agents/skills/**, scripts/lint-skills.ts, and scripts/check-skill-mirror.mjs.
- New 'Skills: project-native lint + mirror' job runs the extended
  lint-skills.ts (schema-driven; required { name, description } + optional
  { license, allowed-tools, metadata }, name pattern check, description
  length check) plus a new check-skill-mirror.mjs byte-integrity script
  (24 mirrored files must match; README.md deliberately per-CLI).
- Wired into 'bun run lint' locally.

Frontmatter validator:
- Rejects unsupported top-level keys (catches category:-style drift).
- Requires name + description.
- Validates name pattern (^[a-z][a-z0-9-]{0,63}$) and description shape
  (non-empty, <=1024 chars).
- Missing frontmatter block itself is a first-class error.

Also strips unsupported top-level 'category:' frontmatter from Jake's
motion-doctrine and cut-the-curve SKILL.mds (both mirrors), rewrites the
TTS invocation from ~/.claude/skills/media-use/... to the tracked
skills/hyperframes-media/scripts/heygen-tts.mjs, swaps npx hyperframes@latest
for the repo-local CLI in the gate step, and fixes a lint issue in Jake's
seam-gate.mjs (ternary-for-side-effect -> if/else).

Validated end-to-end by Home on Jun 23-29 (MP4 posted in C0ACCNHLG3U
thread 1784181166.041319). Independently reviewed R1/R2/R3 by Magi.

Co-authored-by: Jake Moran <jake@heygen.com>
2026-07-16 17:29:19 -04:00
James 83db364f81 test(repo): enforce workspace contracts 2026-07-11 21:57:45 -07:00
James 0af07a07c5 fix(core): enforce strict runtime safety 2026-07-11 21:31:30 -07:00
James 9e7b11998c test(producer): gate source tests by execution lane 2026-07-11 10:38:02 -07:00
Miguel Ángel e73076e93c fix(core): publish runtime inline artifact (#1787) 2026-06-29 14:43:25 -07:00
WaterrrForeverandClaude Opus 4.8 bf630bfe1e fix(cli): always check GitHub skills on init while skills.sh syncs (#1768)
* fix(cli): always check GitHub skills on init while skills.sh syncs

The "don't pass --skip-skills" guidance lives in SKILL.md, which ships
through the laggy skills.sh registry and can't be relied on to reach the
agent — so an agent that improvises `--skip-skills` silently dodges the
GitHub skills freshness pull. Put the guarantee in the CLI instead (the
one channel that updates promptly via `npx hyperframes@latest`):

- Neuter the `--skip-skills` FLAG so it no longer skips the check; gate
  skipping on the HYPERFRAMES_SKIP_SKILLS=1 env var instead (the
  agent/user CLI path never sets it). Print a one-line notice when the
  ignored flag is passed.
- Wire the env escape hatch into the init test helper (one place) and the
  CI smoke-test / windows-canary steps so they stay offline and fast.
- Update the skill docs that previously told agents `--skip-skills` opts
  out.

Temporary measure while skills.sh catches up — revert init.ts's
`skipSkills` to `args["skip-skills"] === true` once it does (noted inline).

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

* fix(ci): build @hyperframes/lint before core in Test and Studio jobs

The lint extraction (#1756) made @hyperframes/lint a runtime dependency of
core — core's compiled compiler/staticGuard.js imports it via the package's
"node" export condition (./dist/index.js). But the Test and Studio-load-smoke
jobs pre-build only @hyperframes/{parsers,studio-server} before packages/core,
so loading core's dist at test / dev-server time fails with:

  ERR_MODULE_NOT_FOUND: Cannot find module .../@hyperframes/lint/dist/index.js
  imported from .../packages/core/dist/compiler/staticGuard.js

Build the canonical pre-core set @hyperframes/{parsers,lint,studio-server}
(the glob the root build script uses) in both jobs so it can't drift again.
The SDK job is left as-is — it builds parsers+core only and passes.

Reproduced locally: removing packages/lint/dist reproduces the exact
ERR_MODULE_NOT_FOUND; building lint resolves it.

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

* fix(cli): address PR #1768 review — stale comment + harden offline init

- Update the stale interactive-path comment that still said "Opt out with
  --skip-skills"; the flag is neutered, opt-out is HYPERFRAMES_SKIP_SKILLS=1.
- Wrap installAllSkills in ensureSkillsCurrent with try/catch. installAllSkills
  is already non-strict (swallows its own failures), but since --skip-skills no
  longer escapes this path, every init — including offline ones that fall through
  to "install anyway" — runs it. The guard guarantees a skills-install failure
  only warns and proceeds, never breaks init.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:57:27 +08:00
Miguel Ángel 7a4853dfe6 refactor: extract @hyperframes/studio-server from core (#1757)
* refactor: extract @hyperframes/studio-server package from core

Moves all studio-api routes, helpers, and Hono server wiring from
packages/core/src/studio-api/ into a new standalone packages/studio-server
package (@hyperframes/studio-server).

Core keeps thin re-export stubs at @hyperframes/core/studio-api and the
subpath helpers (screenshot-clip, draft-markers, etc.) for backward
compatibility. Consumer imports (cli studioServer, vite adapter/config,
producer htmlCompiler, studio manualEditsTypes) are updated to import from
@hyperframes/studio-server directly.

Also exports rewriteInlineStyleAssetUrls from @hyperframes/core root (was
in compiler/rewriteSubCompPaths.ts but not re-exported), required by
@hyperframes/studio-server/helpers/subComposition.

Removes postcss-selector-parser from @hyperframes/core dependencies (moved
to @hyperframes/studio-server which owns the routes that used it).

Depends on @hyperframes/parsers (PR #1755).

* fix(ci): add parsers+studio-server to Dockerfile and build before preview tests

* fix(ci): build @hyperframes/studio-server before Test and studio load smoke

Studio's vite.config.ts imports @hyperframes/studio-server, which resolves
via its "node" export condition to built dist. The Test and studio-load-smoke
jobs only built parsers + core, so esbuild's config load failed to resolve the
package entry. Build studio-server too.

* fix(studio): repoint sdkCutoverParity test import to studio-server

sourceMutation moved from core's studio-api to @hyperframes/studio-server;
the test still imported the deleted core path. This was masked while studio's
vite.config failed to load (couldn't resolve studio-server); now that the
config loads, the test runs and the stale import surfaced.
2026-06-27 01:24:01 -04:00
Miguel Ángel cdf9c817e1 refactor: extract @hyperframes/parsers from core (#1755)
## Summary

Extracts the GSAP parser/writer suite, HTML parser, hf-ids, spring-ease, and the shared composition data types out of `@hyperframes/core/src/parsers/` into a new, independently-publishable **`@hyperframes/parsers`** package.

This is the foundation of the [#1749](https://github.com/heygen-com/hyperframes/issues/1749) effort: make HyperFrames' parsing/linting/validation usable as plain libraries in a Node app, without shelling out to the CLI. Parsers is the standalone base every other extracted package builds on.

**Part 1 of 3** — splits #1754 into independently-reviewable pieces. Parts 2 (lint) and 3 (studio-server) stack on this branch.

## What moves

| | |
|---|---|
| Source moved out of core | **~9,900 LOC** (`src/parsers/` → `packages/parsers/src/`) |
| Total lines removed from core (incl. tests + goldens) | ~19,600 |
| Files relocated | 39 |
| Tests carried over | **660 passing** (5 skipped, 3 todo) |

The big movers: `gsapParser` / `gsapParserAcorn` (the recast + acorn dual parsers), `gsapWriterAcorn`, `gsapSerialize`, `gsapUnroll`, `htmlParser`, `hfIds`, `springEase`, `stableIds`, plus the `__goldens__` corpus.

## Bundle footprint of the new package

| Artifact | Size |
|---|---|
| `dist/` (unpacked) | 1.7 MB |
| npm tarball (packed) | 409 KB |
| `dist/index.js` | 90 KB (**~21 KB gzipped**) |
| Heaviest entries | `gsapWriterAcorn.js` 93 KB · `gsapParser.js` 91 KB |

Most of the weight is the GSAP AST machinery (recast/babel/acorn). It's tree-shakeable via subpath entries (`@hyperframes/parsers/hf-ids`, `/gsap-constants`, etc.) so a consumer that only needs `hf-ids` (2 KB) doesn't pull the parsers.

## How `@hyperframes/core` changes

The interesting part: **core sheds its entire AST toolchain.**

| core `dependencies` | before | after |
|---|---|---|
| count | 9 | 6 |
| removed | — | `@babel/parser`, `acorn`, `acorn-walk`, `magic-string`, `recast` |
| added | — | `@hyperframes/parsers`, `linkedom` |

Before this PR, importing `@hyperframes/core` at all dragged in babel + recast + acorn just to construct types. Now those live behind `@hyperframes/parsers`, and a consumer that only wants core's runtime/compiler types never resolves the parser stack. Core keeps thin `@deprecated` re-export stubs at the old subpaths (`@hyperframes/core/gsap-parser`, `/gsap-constants`, …) so nothing downstream breaks.

## Design notes

- **`"bun"` export condition before `"node"`** in every package export. Bun resolves the TypeScript source directly (no pre-built `dist/`), while Node/tsx/Docker contexts fall through to `"node"` → `dist/`. This keeps the dev loop zero-build while published artifacts stay Node-consumable.
- `@hyperframes/parsers` is **standalone** — zero `@hyperframes/*` dependencies — so it can be the base of the stack.

## Test plan

- [x] `bun run --filter @hyperframes/parsers test` — 660 tests pass
- [x] `bun run --filter @hyperframes/sdk test` — 382 tests pass
- [x] `bun run build` — full monorepo build succeeds
- [x] Fallow audit passes on CI
2026-06-27 00:46:26 -04:00
WaterrrForeverandClaude Opus 4.8 d70ee134cc feat(cli): add skills version check, update, and freshness manifest (#1738)
* feat(cli): add skills version check, update, and freshness manifest

Give the HyperFrames skill bundle a content fingerprint so agents and
users can tell whether installed skills are the latest version, on any
platform that can run the CLI.

- skills-manifest.json (repo root): per-skill sha256 over the whole skill
  directory; minimal {source, skills}, no version/timestamp so it is fully
  deterministic. Generated by scripts/gen-skills-manifest.ts.
- `hyperframes skills check` [--json]: compares installed skills to the
  manifest; exits non-zero when something is outdated (agent/CI gate).
- `hyperframes skills update`: thin wrapper over `npx skills update`.
- Passive nudge on render/lint/validate when skills are stale (24h cache,
  same opt-out as the CLI self-update notice).
- "latest" resolved via `git ls-remote` + SHA-pinned raw URL to dodge
  GitHub raw-CDN lag, falling back to the main branch URL.
- CI job + lefthook hook keep skills-manifest.json in sync with skills/.

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

* fix(cli): add execFile to child_process mock in skills test

skills.test.ts mocks node:child_process but only declared execFileSync
and spawn. Loading skills.js transitively loads skillsManifest.ts, which
runs promisify(execFile) at module load, so vitest threw on the missing
execFile named export. Add a bare stub — these tests never invoke it.

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

* feat(cli): init installs all skills; skills update pulls the full set

Make `hyperframes init` the single place skills are pulled in full, and
make "update" mean "get everything" rather than "refresh what's there".

- init now always installs/refreshes ALL skills (incl. ones not yet
  present) instead of prompting "Install AI coding skills?" — opt out
  with `init --skip-skills`. Both the interactive and non-interactive
  paths pass `--all --yes` so the complete set is fetched.
- `hyperframes skills update` switches from `npx skills update` (which
  only refreshes already-installed skills) to `skills add --all`, so it
  installs missing skills too — the same install step init runs.
- SKILL.md documents init-installs-all and the new update semantics.

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

* feat(cli): skills check treats missing skills as needing an update

The full skill set is now the goal (init and `skills update` both pull
all, including ones not installed), so a partial install is no longer
"a choice" — it's something to fix.

- diffSkills: updateAvailable is now true when anything is outdated OR
  missing (local-only still doesn't count). So `skills check` exits
  non-zero — and renders "Update:" instead of "up to date" — whenever a
  skill is missing, not just when one is stale.
- The passive render/lint/validate nudge follows suit: it now counts
  missing alongside outdated ("N skills out of date or missing"),
  tracked via a new skillsMissingCount cache field.
- SKILL.md documents the stricter check.

Note: platforms that intentionally vendor only a subset of skills (e.g.
a Codex snapshot) will now see check report non-zero.

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

* fix(cli): install/update skills straight from the GitHub repo

`skills add owner/repo` can resolve through the skills.sh registry, which
lags behind the repo — so `update` could install a stale version while
`check` (which resolves latest directly from GitHub) keeps reporting
"outdated", an endless loop.

Switch the install source to the full GitHub URL
(https://github.com/heygen-com/hyperframes), which makes `skills add`
git-clone the repo directly at latest main, bypassing the registry. This
covers `hyperframes skills`, `hyperframes skills update`, and `init`'s
skill install — all of which go through SOURCES. Now install/update and
check agree on what "latest" means.

The init "install skills" hint now points at `npx hyperframes skills
update` so the manual path uses the same GitHub-direct fetch.

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

* feat(cli): init checks skills against GitHub, installs only when stale

`hyperframes init` now runs the skills version check first and only
(re)installs when something is outdated or missing — instead of
unconditionally re-pulling every time. Re-running init on an
already-current project is now a no-op ("skills are already up to date").

- New ensureSkillsCurrent() helper, shared by both the interactive and
  non-interactive init paths (no duplicated install logic).
- The check resolves "latest" straight from GitHub (same source the
  install uses); best-effort — if it can't reach GitHub it installs anyway.
- SKILL.md updated to describe the check-then-install behavior.

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

* refactor(cli): address skills manifest review feedback

From the PR review (points 1, 2, 4, 5):

1. Remove the `local-only` skill status. checkSkills only ever hashes
   manifest-listed skills, so a local-only status could never appear in
   the end-to-end output — and making it appear would wrongly flag
   unrelated skills (the `.../skills` dir is shared across sources).
   diffSkills now reports only on manifest skills; skills on disk that
   aren't in the manifest are ignored.
2. Drop the redundant per-directory sort in listFilesSorted — the single
   final out.sort() is what guarantees a deterministic hash (verified:
   manifest unchanged).
4. resolveLatestManifest local-path detection now uses path.isAbsolute,
   so Windows absolute paths (C:\...) are treated as local instead of
   falling through to a remote fetch.
5. fetchManifest validates the response shape (asSkillsManifest) instead
   of a blind `as` cast, so a CDN error page served as 200 fails with a
   clear error rather than a cryptic crash later in diffSkills.

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

* fix(cli): strict skills update + auto-discover any agent host

Address PR review (Magi blocker + James/Rames robustness):

- Blocker (Magi): `skills update` is the documented recovery path for
  `skills check || skills update`, but it delegated to installAllSkills()
  which swallowed missing-npx and failed `skills add` as "skipped",
  exiting 0 even when nothing changed. Add a strict mode that throws on
  failure; update sets a non-zero exit (init stays best-effort). New tests
  simulate a non-zero `skills add` (exit 1) and the success path.

- Robustness (James/Rames #2): the upstream `skills` CLI installs into
  ~72 agent conventions; a hard-coded list (4, or even 11) can't track
  that. Replace defaultSkillRoots with discoverSkillRoots — it scans cwd +
  $HOME for any `<host>/skills/<manifest-skill>/SKILL.md` (plus the XDG
  `.config/<host>/skills`), so detection is structural and future-proof,
  no closed list. agentFromDir infers the host from the path.

- Tests (Rames #3): temp-fixture detection tests for every convention ×
  {project, global}, scope priority, claude-code preference, the
  no-install case, the --dir override, and an unknown/new host (proving
  the no-closed-list property).

- Docs (Rames #4/#5): SKILL.md notes init's best-effort GitHub round-trip;
  findRepoManifest climbs 16 levels (was 8) for deep monorepos.

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

* fix(cli): resolve CodeQL file-system race + de-flake Windows npx test

Two CI fixes:

- CodeQL (high, js/file-system-race) at gen-skills-manifest.ts: the
  existsSync(outPath) precheck followed by writeFileSync(outPath) is a
  check-then-write race. Read the committed manifest directly in a
  try/catch instead (missing/unreadable ⇒ "no committed manifest"), so
  there's no precheck to race against. Behavior is unchanged.

- Windows Tests: npxCommand.test.ts's real `npx --version` smoke test
  cold-starts slower than vitest's 5s default on Windows runners and
  timed out. Give the test 60s headroom (and a 30s exec timeout). Kept
  as a real execution check — mocking would reduce it to a tautology.

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

* fix(cli): repair garbled npx smoke-test timeout comment

The explanatory comment for the 60s timeout was scrambled across the
callback/timeout arguments, failing oxfmt --check (and thus preflight,
which in turn skipped preview-parity and failed the regression gate).
Move it above the it() call so it no longer sits between call arguments.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:52:42 +08:00
James Russo 0c05025c04 ci(skills): run skills/**/*.test.mjs in CI (#1724)
skills/**/*.test.mjs files (e.g. skills/media-use/scripts/resolve.test.mjs
and skills/media-use/scripts/lib/manifest.test.mjs) are bare `node --test`
files with only `node:` built-in imports. They aren't part of any workspace
package, and the existing `Test` job's path filter (the `code` filter in
the `changes` job) excludes `skills/**`, so even on PRs that touch only
skills/ those tests never run.

This matters for regression guards. The shell-injection probe test added in
HF#1723 feeds probe() a filename containing `clip"; touch INJECTED; echo
".mp4` and asserts no marker file is created. The test passes locally but
under the current job graph it would never run in CI on a follow-up skills/
change that re-introduces the bug.

Closing the gap with a dedicated `Test: skills` job rather than relaxing
the `code` filter. The existing `Test` job's steps run `bun run test:scripts`
(hardcoded file list) and `bun run --filter '*' test` (workspace packages
only), neither of which would actually execute skills tests even if the
filter let `skills/**` through. The dedicated job needs no `bun install`,
just node 22, since the tests only import from `node:` and relative paths.

The discovery step shells out to `find` and fails loudly when zero test
files match, so a future rename or layout change can't silently turn this
into a no-op pass.

Spotted by Via in HF#1723 review thread, confirmed by James as a separate
follow-up rather than a blocker for HF#1723.

--
Jerrai (https://claude.com/claude-code)
2026-06-25 12:50:43 -07:00
Miguel Ángel 041f2fa196 fix(media-use): kill shell command injection in probe/heygen-search/eval
Swap execSync(<shell-string>) → execFileSync(file, [argv]) in probe.mjs, heygen-search.mjs, and eval.mjs so hostile filenames / queries / manifest metadata can't inject shell. Adds probe.test.mjs regression guard and a CI Test (skills) job so it actually runs. Closes the media-use High/Critical scanner alert.
2026-06-25 15:16:52 -04:00
Miguel Ángel 25b717dd9c fix(cli): resolve npx shims on Windows (#1626) 2026-06-21 17:50:22 -04:00
Miguel Ángel a241f2591e fix(studio): break all 7 circular dependency cycles and fix rules-of-hooks violation (#1422) 2026-06-13 19:23:05 -04:00
Vance IngallsandClaude Sonnet 4.6 a0ee97210b fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors (#1350)
* fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors

* test(sdk,ci): smoke test + explicit sdk-tests CI gate

Smoke test covers the full public surface:
  openComposition → setStyle/setText/dispatch(moveElement) → serialize
  applyPatches + ORIGIN_APPLY_PATCHES tagging
  batch() coalescing + transactional rollback on throw
  undo/redo round-trip
  persist adapter write + persist:error surfacing
  T3 embedded mode: override-set apply on open + getOverrides round-trip

Adds sdk-tests CI job so SDK coverage is explicitly named and required —
prevents a repeat of the demo-next vitest-never-ran incident.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): export adapter types, awaitable flush(), never-coalesce mode

- Export PersistAdapter, PreviewAdapter, PersistVersionEntry from package
  root — callers can now write typed fakes without reaching into internals
- Add flush(): Promise<void> to Composition interface + CompositionImpl —
  app-close handlers can await a clean drain of the persist queue
- coalesceMs <= 0 disables coalescing entirely in createHistory — enables
  deterministic test scenarios without per-entry timestamp manipulation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(sdk): p2 edge cases — setText no-text-node, override-remove non-existent, flush in smoke

- setText on element with no prior text node (firstTextIdx=-1 path)
- applyOverrideSet null removal on non-existent prop is a no-op (no throw)
- smoke persist test uses comp.flush() instead of setTimeout
- can() JSDoc clarifies Phase 3b false-return is intentional feature-detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: trigger regression suite

* fix(ci): add packages/sdk/package.json to Dockerfile.test workspace copy

bun install --frozen-lockfile fails in the regression Docker build because
the lockfile references the sdk workspace member but its package.json was
not copied into the image before the install step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:07:49 -07:00
Miguel Ángel c12987e301 fix(producer): revert Proxy-based wrapTimeline to plain-object approach (#1284)
* fix(producer): revert Proxy-based wrapTimeline to plain-object approach

The `new Proxy` wrapper for GSAP timelines introduced in #1279 causes
Chrome headless to hang indefinitely during page.goto — DOMContentLoaded
never fires. The plain-object approach (explicit method allowlist) loads
in <800ms on the same composition.

The Proxy's generic get/set traps interact badly with Chrome's internal
object inspection (Symbol checks, thenable probing, DevTools serialization)
during HTML parsing, creating a permanent navigation hang. The
maybePublishRenderReady listener fix from #1279 is preserved — only the
wrapTimeline implementation is reverted.

Compositions using GSAP methods outside the allowlist (eventCallback,
labels, repeat, etc.) will see those calls silently dropped rather than
forwarded. This is the same behavior as v0.6.81 and earlier. A safer
forwarding approach can be explored separately without blocking renders.

* fix(producer): address review — stale meta.json descriptions + silently-dropped methods doc

- three-boundary: description referenced Proxy fix but the test uses
  onUpdate in to() vars (allowlist path), not eventCallback
- three-boundary-deferred: same — pins Bug 2's deferred-race, not Bug 1
- Add inline doc comment listing silently-dropped GSAP methods and the
  onUpdate workaround

* ci: add page.goto timing canary to CLI smoke test

Parse page.goto completion times from the render log and fail if the
slowest navigation exceeds 5s. Catches wrapTimeline regressions that
block DOMContentLoaded before the 60s timeout fires.

Refs: #1285

* fix(producer): forward all GSAP methods via dynamic enumeration at wrap time

Instead of silently dropping methods outside a static allowlist, enumerate
the real timeline's prototype chain at wrap time and generate plain-object
forwarding stubs for every method not already covered.

This achieves the same coverage as the `new Proxy` approach from #1279
without the Chrome headless navigation hang — no Proxy trap surfaces are
exposed to Chrome internals. Methods prefixed with `_` (GSAP private) are
skipped. All forwarded methods flush pending batch operations before
delegating, matching the existing allowlist behavior.

Closes #1285

* fix(producer): make proxy non-thenable + harden CI canary

- Skip `then` in forwardRemainingMethods — GSAP timelines are thenable
  (tl.then resolves on completion), and forwarding it makes the proxy
  thenable too: Promise.resolve(proxy) or await proxy hangs forever for
  paused timelines
- Add unit test: Promise.resolve(proxy) resolves immediately, real
  then() is never called
- CI canary: exit 1 (not 0) when no page.goto timing is found in logs,
  so a log-format change loudly breaks CI instead of silently disabling
  the canary
2026-06-08 21:10:43 -04:00
Vance IngallsandClaude Sonnet 4.6 25420bf4cf ci: skip ffmpeg-static CDN download on ubuntu; retry Windows FFmpeg install (#1275)
* ci: skip ffmpeg-static CDN download on ubuntu; retry Windows FFmpeg install

ubuntu-24.04 runners ship /usr/bin/ffmpeg. Set FFMPEG_BIN so ffmpeg-static's
postinstall script skips its GitHub-release binary download, preventing bun
install failures when that CDN is unavailable.

For Windows: increase BtbN/FFmpeg-Builds download max-attempts 3→8 with
longer backoff (30×attempt s) and set FFMPEG_BIN after install so bun install
also skips ffmpeg-static's download in both render and test jobs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: fix FFMPEG_BIN approach — use writable copy via composite action

/usr/bin/ffmpeg is not writable by the runner user. When bun runs
ffmpeg-static's postinstall script in a context where process.exit(0) is
intercepted, the skip-if-exists check has no effect and the download proceeds
to the destination path. Pointing FFMPEG_BIN at a system path (/usr/bin/ffmpeg)
therefore causes EACCES even when the CDN returns 200.

Replace the top-level env var with a prepare-ffmpeg-bin composite action that
copies the system ffmpeg to $RUNNER_TEMP (writable). Call it before every
bun install step in the CI workflow. Whether the postinstall script skips or
overwrites, the write target is now writable and the job succeeds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: skip apt fallback in prepare-ffmpeg-bin; use stub when ffmpeg absent

ubuntu-24.04 GHA runners do not have ffmpeg pre-installed. The apt-get
fallback triggered the install of ffmpeg and its dependencies, but the
Azure apt mirror returned 404 for libcaca0, aborting the composite action.

ffmpeg-static's postinstall only needs a regular file to exist at FFMPEG_BIN
in order to reach the statSync check and call process.exit(0) — it does not
need a real executable. Write a minimal shell stub when 'which ffmpeg' returns
empty. Jobs that require an actual ffmpeg binary (cli-smoke-required) install
it via apt before calling this action, so 'which ffmpeg' returns the real path
and the copy branch runs instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:22:31 -07:00
James RussoandClaude Opus 4.8 248f640734 feat(docs): add changelog release workflow (#1164)
* feat(docs): add changelog release workflow

* fix(scripts): resolve CodeQL findings in release scripts

- draft-changelog.ts: replace existsSync+writeFileSync check-then-act with
  an atomic exclusive-write flag (flag: wx) to fix the js/file-system-race
  TOCTOU finding; overwrite only under --force (flag: w).
- set-version.ts: switch execSync shell-string git calls to execFileSync with
  argument arrays so the interpolated version/paths can never be interpreted
  by a shell, resolving the js/indirect-command-line-injection findings.

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

* fix(scripts): lower writeReleaseNotes complexity below CRAP threshold

The exclusive-write fix pushed writeReleaseNotes to cyclomatic 5 / CRAP 30.0
(fallow/high-crap-score, threshold 30.0). The '!force' guard in the catch is
redundant — EEXIST is only reachable under the 'wx' flag (force=false), since
'w' overwrites without throwing. Dropping it returns the function to cyclomatic
4 / CRAP 20 with identical behavior.

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

* fix(docs): address changelog review feedback

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:51:16 -07:00
Miguel Ángel ea4d920589 refactor(studio): split oversized files and raise line limit to 600
Split PlayerControls.tsx into focused sub-components (SeekBar,
WorkAreaOverlay, MuteButton, LoopButton, FullscreenButton,
ShortcutsPanel, SpeedMenu) and extracted seek bar drag/progress
tracking into useSeekBarDrag hook.

Split manualEditsDom.ts patch-builder functions into
manualEditsDomPatches.ts with data-driven helpers to reduce
duplication and complexity.

Extracted per-type reapply helpers from reapplyPositionEditsAfterSeek
and factored out identity-matrix check from
stripGsapTranslateFromTransform.

Raised file-size limit from 500 to 600 lines, removed
.filesize-allowlist.
2026-05-22 22:57:12 -04:00
JamesandClaude Opus 4.7 6d1236a0cc feat(cli): add --output-resolution to lambda render
Allows authored-at-1080p compositions to render at 4K/2K via Chrome
deviceScaleFactor supersampling without re-laying-out the composition.
Plain --width 3840 silently lays out at 1920×1080 because data-width/
data-height attrs override Config.width — this flag is the supported
way to ask the renderer to supersample.

Accepts canonical CanvasResolution names (landscape, landscape-4k,
portrait, portrait-4k, square, square-4k) and aliases (1080p, 4k, uhd,
hd, 1080p-portrait, 4k-portrait, 1080p-square, 4k-square). Wired
through render + render-batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:08:30 -04:00
Miguel Ángel be9b61a8c9 Merge pull request #986 from heygen-com/fix/studio-edit-persistence-and-render-css
fix(studio): server-side DOM patching, render CSS scoping, and resilience
2026-05-21 05:53:32 +02:00
Miguel Ángel 692c1431c3 fix(ci): resolve puppeteer from producer package in studio smoke test 2026-05-20 17:32:55 -04:00
Miguel Ángel 1d6ed9f41a ci: add studio load smoke test — catches runtime errors on first page load 2026-05-20 17:30:05 -04:00
James 07bcb4f73b fix(cli): stop dropping CI/agent telemetry, suppress HeyGen CI at workflow level
The CI=true early-exit in shouldTrack() was hiding most modern usage
(coding agents in Codespaces, CI pipelines, agent sandboxes). Remove it.
Each event still carries is_ci/is_docker/is_tty from system.ts, so CI vs
laptop traffic can be separated in PostHog without being dropped at
ingestion.

HeyGen's own CI is suppressed via HYPERFRAMES_NO_TELEMETRY=1 added to
each workflow that exercises the CLI.
2026-05-20 01:03:41 -04:00
James Russo efc5f0584b ci: post sticky PR comment with fallow audit findings (#954)
* ci: run fallow audit in lefthook pre-commit

Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.

Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.

Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.

The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.

* refactor: delete orphan declarations flagged by fallow

After fallow's auto-fix de-exports unused symbols, oxlint surfaces them
as no-unused-vars. This PR deletes those orphan declarations outright.

Biggest cleanup: studio/src/icons/SystemIcons.tsx shrinks from 132 to 57
lines — 33 unused icon wrappers and their phosphor-icon imports deleted.

Other deletions across 14 more files covering paired getter/setters,
helper functions, dead env constants, internal components with no
callers, and cascading unused imports.

Cascade-causing files held back for follow-up PRs: renderOrchestrator
barrel of captureCost re-exports, telemetry/portUtils/remote barrels,
Button.tsx + ui/index.ts (would orphan whole file), studioMotion
type re-exports.

Test plan: typecheck clean across 8 packages, oxlint + oxfmt clean,
fallow audit exit 0 (remaining findings inherited), cli + studio
vitest suites pass.

* ci: post sticky PR comment with fallow audit findings

Reviewers shouldn't have to dig through CI logs to see what fallow
flagged. With this change, on every PR the fallow job posts (or
updates) a sticky comment containing the full audit report formatted
as a collapsible markdown table.

The comment uses fallow's built-in `pr-comment-github` format, which
already emits a `<!-- fallow-id: fallow-results -->` sentinel.
`marocchino/sticky-pull-request-comment@v2.9.1` matches that header so
each run replaces the previous comment instead of stacking new ones.

The job now runs in three steps:
1. Run `fallow audit ... --format pr-comment-github` with
   `continue-on-error: true` so the comment posts even when the audit
   fails. Exit code is captured.
2. Post (or update) the sticky comment with the captured output.
3. Re-emit the audit exit code so the job still fails-the-build on
   new findings.

Bumps the workflow's `pull-requests` permission from read to write,
needed for the sticky-comment poster to call the issues API.
2026-05-18 21:28:41 -07:00
James 62c800aec3 ci: add fallow audit job (PR-scoped, new-only gate) 2026-05-18 21:18:01 +00:00
Miguel Ángel 91bdffffe6 fix(ci): scope LOC check to studio, split useTimelinePlayer + hyperframes-player under 500 LOC (#750)
* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each)

* fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files

* feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson

Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux:
- Detects the platform automatically
- Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM)
- Falls back to clear manual instructions with exact commands
- 'hyperframes browser ensure' guides through the setup interactively
- After setup, all render commands work without any flags

* fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds

Path exclusions are insufficient — Defender re-scans new files created
during bun install before the exclusion takes effect. Disable real-time
monitoring for the entire job duration instead (standard CI practice).

* refactor(studio): split all files >500 LOC + extract useToast, delete allowlist

All 11 large files split into focused modules under 500 LOC.
App.tsx extracted toast logic into useToast hook (493 LOC now).
.filesize-allowlist deleted — no longer needed.

* fix: remove unused imports from split files, extract useToast from App.tsx

App.tsx: 504 → 493 lines (toast logic extracted to useToast hook)
timelineDOM.ts: remove unused imports from re-export pattern
MotionPanel.tsx: remove unused clampStudioCustomEasePoints import
studioMotionOps.ts: remove unused StudioGsapMotionDirection import

* fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs)

* fix(producer): use node --experimental-strip-types instead of tsx for build:fonts

Eliminates the tsx binary dependency that Windows Defender locks during
bun install, causing EPERM errors. Node 22.6+ strips TypeScript types
natively with no external binary.

* chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500)

* fix(ci): disable Windows Defender before checkout to prevent all EPERM races

* fix(producer): skip build:fonts if fontData.generated.ts already exists

The generated file is tracked in git, so CI doesn't need to regenerate
it. This avoids @fontsource/inter node_modules access on Windows which
triggers EPERM from Defender scanning during bun install.
2026-05-13 01:48:12 +02:00
38efe168e2 refactor(studio): contexts, PropertyPanel split, duration fix, perf (#748)
* feat(studio): add manual DOM editing inspector (#466)

* fix: stabilize studio preview and runtime sync

* fix: pass selector through timeline thumbnails

* feat: add studio timeline editing

* fix: disambiguate timeline edit targets

* fix: stop timeline auto-scroll in fit mode

* feat: use percentage-based timeline zoom

* fix: sync timeline playhead on zoom changes

* fix: reset timeline scroll when returning to fit

* feat(studio): add manual DOM editing inspector

* docs: update studio manual dom editing guide

* feat(studio): add image asset picker for fills

* feat(studio): add inline image uploads for fills

* fix(studio): use real file input for image fill uploads

* fix(studio): restore toast plumbing after rebase

* fix(studio): explain in-app upload limitation

* fix(studio): reuse asset-tab upload pattern in fills

* feat(studio): refine manual design inspector

* fix(studio): polish manual design inspector

* fix(studio): keep color picker in viewport

* fix(studio): clarify color picker selection

* docs: update manual DOM editing guide

* fix(studio): keep gradient color picker open

* fix(studio): scope text color to text layers

* fix(studio): add agent fallback for immovable layers

* fix(studio): address manual editing review feedback

* fix(studio): make local font selection reliable

* fix(studio): improve dom picking and thumbnails

* fix(studio): copy absolute paths in agent prompts

* fix(studio): prevent timeline track cutoff

* fix: copy Studio agent prompts in Safari

* fix(studio): hold canvas movement from inspector

* feat(studio): add persistent undo redo (#537)

Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops.

The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit.

- Adds a persistent per-project edit-history model for file snapshots.
- Stores undo/redo stacks in IndexedDB so history survives Studio refreshes.
- Records source editor saves, manual DOM edits, and timeline mutations.
- Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`.
- Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content.
- Keeps history available in memory if IndexedDB persistence fails during a session.
- Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper.

Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit.

Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot.

- `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass
- `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass
- `bun --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors
- `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts`
- `git diff --check`
- `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck
- Lefthook pre-commit -> lint, format, typecheck pass
- Lefthook commit-msg -> commitlint pass

- Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`.
- Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`.
- Refreshed Studio and verified Undo stayed enabled.
- Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned.
- Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move.
- Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`.

- Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed.
- The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed.
- The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request.

* fix: align Studio capture with preview (#595)

Studio frame capture could fail for projects mounted outside the repo when the project id came from an encoded hash route. A project like `Notion Showcase` loaded as `#project/Notion%20Showcase`, but the capture URL encoded that already-encoded value again, producing `/api/projects/Notion%2520Showcase/...` and a 404.

While validating the fix by seeking through the preview, capture also diverged from the visible player for nested compositions because the thumbnail route sought raw timelines instead of the same player seek path used by Studio preview.

- Decodes project ids when reading Studio `#project/...` routes and centralizes project hash/API path construction.
- Keeps API URLs encoded exactly once, including project names with spaces, literal `%`, reserved characters, and unicode.
- Updates Studio thumbnail capture to prefer `window.__player.seek(t)` and only fall back to raw timeline seeking for standalone pages.
- Preserves explicit `t=0` thumbnail requests instead of falling back to `0.5` seconds.
- Adds preview-regression CI coverage for Studio routing, frame capture URL construction, thumbnail seeking, and core thumbnail seek parsing.

Studio treated the hash route segment as the canonical project id even when the browser had already percent-encoded it. `buildFrameCaptureUrl` then encoded that string again, so a decoded project directory name and the capture API path no longer matched.

The preview/capture mismatch was a separate seek-path issue: the visible Studio preview seeks through the HyperFrames player, which maps global time into nested composition time. The capture route bypassed that layer and paused all registered timelines at the same global time.

The zero-second capture case came from parsing `t` with a truthiness fallback, so `parseFloat("0") || 0.5` became `0.5`.

- `bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts`
- `bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts`
- `bunx oxfmt --check .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bunx oxlint .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts`
- `bun run --cwd packages/studio typecheck`
- `bun run --cwd packages/core build:hyperframes-runtime`
- `bun run --cwd packages/core typecheck`
- `git diff --check`

Pre-commit also reran lint, format, and typecheck successfully for the committed files.

Using `agent-browser`, I mounted `/Users/miguel07code/Downloads/Notion Showcase` into Studio's project data and opened:

```text
http://127.0.0.1:5197/#project/Notion%20Showcase
```

Before the fix, Capture requested `/api/projects/Notion%2520Showcase/thumbnail/index.html?...` and Studio showed `Capture failed`.

After the fix, I sought the preview to `0s`, `2s`, `10s`, and `18s`, captured each frame, and compared the visible preview crop against the capture output. The capture URLs all used `Notion%20Showcase`, not `Notion%2520Showcase`, and no failure toast appeared.

Mean pixel diffs for preview vs capture were:

- `0s`: `0.0`
- `2s`: `0.8641`
- `10s`: `0.3496`
- `18s`: `0.2309`

The small non-zero diffs are raster/antialias-level differences after resizing the capture to the preview crop dimensions.

- Browser screenshots, comparison sheets, network logs, and the `agent-browser` recording are local-only under `qa-artifacts/capture-button/` and are not committed.
- The local Notion Showcase project mount is an ignored symlink under `packages/studio/data/projects/` and is not committed.
- Thumbnail cache versions were bumped so stale captures generated with the old seek behavior are not reused.

* feat: persist studio manual edits via manifest

* fix(studio): stabilize manual edit manifest rendering

* fix(studio): allow master canvas layer selection

* fix(studio): scale master edits in source coordinates

* fix(studio): reapply manual edits during playback

* fix(studio): keep rotation edit base stable

* feat(studio): highlight hovered canvas target

* fix(studio): drag hovered canvas targets immediately

* fix(studio): rotate manual edits around center

* fix(studio): keep rotate handle aligned while dragging

* fix(studio): allow small rotation adjustments

* fix(studio): match rotate handle size to resize handle

* fix(studio): connect rotate handle line to selection

* feat(studio): reset selected manual edits

* fix(studio): route inspector geometry through manual edits

* feat: add studio group repositioning

* fix: preserve studio group selections

* fix: seed additive studio selection groups

* fix: select studio groups on pointerdown

* fix: harden studio group overlay events

* fix: address studio manual edit review feedback

* fix: apply nested manual edits in drilled previews

* fix: commit drag offsets from gesture math

* fix: persist manual preview edits on refresh

* fix: harden manual edit refresh apply

* fix: share manual edit render runtime

* chore: release v0.5.0-alpha.15

* feat(core): add studio animation preview APIs

* feat(studio): add alpha editor layer inspector

* chore: release v0.6.0-alpha.1

* feat(studio): enable inspector panels by default

* fix(studio): keep motion panel opt-in

* chore: release v0.6.0-alpha.2

* feat: auto-open timeline clip layers

* feat: show composition loading in studio

* feat: disable Studio timeline while composition loads

* chore: ignore .claude directory

* chore: release v0.6.0-alpha.3

* feat(studio): simplify inspector selection ux

* fix(studio): keep notion preview playback moving

* fix(studio): handle raster inspector clicks

* fix(studio): stale selection, rotation control, design panel polish

Fixes and improvements based on power-user testing feedback:

1. Fix stale selection after style edits — handleDomStyleCommit now
   calls refreshDomEditSelectionFromPreview after persisting, matching
   every other commit handler. Without this, the PropertyPanel showed
   frozen computedStyles after color/radius/shadow edits, making it
   look like editing "didn't work." Also adds error handling around
   the persist call.

2. Add rotation field to the Design panel Layout section — reads the
   current rotation angle from the manual edit manifest and commits
   via the existing handleDomRotationCommit handler.

3. Enable motion panel by default — STUDIO_MOTION_PANEL_ENABLED now
   defaults to true so the Motion tab is discoverable without env vars.

4. Color controls only when element has color — fill color section now
   only shows when the element has an explicit non-transparent
   background-color. Text color shows only when the element has a
   color style. Prevents showing color pickers on elements where
   color edits have no visible effect.

5. Exclude canvas from selection — added "canvas" to
   DOM_LAYER_IGNORED_TAGS so canvas elements are not selectable in the
   preview or listed in the layer panel.

6. Multi-selection feedback — shows "N elements selected" with
   guidance instead of the generic empty state when multiple elements
   are selected.

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

* fix(studio): prevent browser launch timeout from crashing dev server

The shared Puppeteer browser pool in getSharedBrowser() could throw a
30s TimeoutError during launch. This error propagated as an uncaught
rejection and killed the vite process, even though generateThumbnail
had its own try/catch — the browser launch promise rejected outside
that scope. Now getSharedBrowser itself catches launch failures and
returns null, so thumbnails degrade gracefully instead of crashing.

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

* fix(studio): revert motion panel default to false

Motion panel stays opt-in via env var per product direction. Only
the Design panel is enabled by default.

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

* fix(studio): prevent read-only property crash in manual edit wrappers

The seek/play/applyAfter wrapper functions in manualEdits.ts crashed
with "Cannot set property X which has only a getter" when the player
or timeline objects define seek/play as getter-only properties. This
prevented ALL manual edits (position, rotation, size) from persisting
to disk — the error thrown during applyCurrentStudioManualEditsToPreview
aborted the save queue.

Wrapped all three property assignments in try/catch so wrapping
gracefully degrades when the target object is non-configurable.

Verified: position edit (X=42px) now persists to
.hyperframes/studio-manual-edits.json and survives page refresh.

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

* fix: alpha preview e2e fixes — exports, init templates, EPIPE crash

Three bugs found via automated e2e testing of the v0.6.0-alpha preview:

1. core: add missing package.json export specifiers for
   studio-api/manual-edits-render-script and
   studio-api/studio-motion-render-script — the alpha.3 npm publish
   failed because the studio build could not resolve these sub-paths.

2. cli: fix init --example creating empty projects — tsup leaves empty
   template directories in dist/ during the build, causing
   existsSync(templateDir) to return true and skip the remote fetch
   fallback. Now checks for index.html inside the dir instead.

3. engine: fix unhandled EPIPE crash in streaming encoder — ffmpeg
   stdin/stdout had no error handlers, so a write after the ffmpeg
   process exits throws an uncaught error that crashes the process.

Verified with 8 consecutive e2e iterations (424 test runs, 0 flaky).

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

* fix(studio): thumbnail crash, feature defaults, multi-select UX, fps selector

Power-user audit fixes for the alpha studio:

- vite.config.ts: wrap thumbnail generation in try/catch so Puppeteer
  TimeoutError doesn't crash the entire vite dev server as an uncaught
  rejection. Close the page on error to prevent browser session leaks.

- manualEditingAvailability.ts: enable motion panel and manual canvas
  drag editing by default (were both false, undiscoverable without
  knowing the env vars).

- PropertyPanel.tsx: show "N elements selected" feedback when multiple
  elements are selected instead of the generic "Select an element"
  empty state.

- RenderQueue.tsx + App.tsx: add FPS selector (24/30/60) to the render
  export bar instead of hardcoding 30fps. Pass the user's choice
  through to startRender.

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

* chore: release v0.6.0-alpha.4

* fix(runtime): update clock duration when root timeline is late-bound

Compositions with external sub-compositions (like apple-presentation
with 7 slides) load child compositions via fetch(). The root GSAP
timeline is only bound after all external compositions finish loading,
but the TransportClock duration was only set during initial setup.

When bindRootTimelineIfAvailable runs after the external compositions
load, it captures the root timeline but never updates the clock.
player.getDuration() continues returning 0, so the player's probe
interval never fires the 'ready' event, and the Studio shows "Loading
composition" indefinitely.

Now bindRootTimelineIfAvailable updates clock.setDuration when the
root timeline is late-bound. Guarded with try/catch for the early call
site where clock is not yet initialized (temporal dead zone).

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

* fix(studio): block element selection while composition is loading

Prevent users from selecting elements in the preview while the
composition is still loading (showing "Loading composition" overlay).
Selection and hover highlighting are suppressed until the player fires
the ready event.

Also reverts motion panel and manual drag editing defaults to false —
these were accidentally set to true during the PR #693 merge.

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

* chore: release v0.6.0-alpha.5

* chore: release v0.6.0-alpha.6

* fix(runtime): remove per-tick timeline.pause() that causes audio stutter

The seekRuntimeTimeline helper added timeline.pause() before every
totalTime() seek. During transport-driven playback, this runs 60 times
per second, causing GSAP to cascade pause events to media elements on
every frame. The result: audio plays/stops/plays/stops in a stutter
pattern.

The captured root timeline is already paused once in player.play() —
the TransportClock drives it via totalTime(t) which keeps it paused.
The extra per-tick pause() was redundant for the root timeline but
actively harmful for media sync.

Fix: restore the original inline seek for the captured timeline
(totalTime without pause), keep seekRuntimeTimeline with pause() only
for standalone child timelines where explicit pause control is needed.

Also fixes rebase artifact: missing PropertyPanel props in App.tsx.

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

* chore: release v0.6.0-alpha.7

* fix(studio): restore text field handlers lost in rebase

Restores handleDomAddTextField and handleDomRemoveTextField that were
dropped when resolving App.tsx conflicts during the main→next rebase.

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

* chore: release v0.6.0-alpha.8

* fix(runtime): comprehensive audio stutter fix

Three changes that together caused audio play/stop/play/stop stutter
during transport-driven playback:

1. seekRuntimeTimeline called timeline.pause() before every totalTime()
   seek, 60x per second. GSAP cascades pause to media elements on every
   frame. Fix: restore original inline seek for the captured timeline
   (totalTime without pause). The timeline is already paused once in
   player.play(). seekRuntimeTimeline with pause() remains only for
   standalone child timelines.

2. player.play() removed the !tl guard, allowing play without a
   captured timeline. But getSafeTimelineDurationSeconds(null) returns
   0, so the clock has no duration → immediately reaches end → stops →
   restarts. Fix: when no timeline provides duration, fall back to the
   root composition element's data-duration attribute.

3. Audio source attachment added networkState guard that could cause
   the clock to flicker between audio-source and monotonic timing
   on transient media states. Fix: keep !rawEl.error guard (prevents
   errored audio from freezing the clock) but drop the networkState
   check.

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

* fix(runtime): skip drift corrections on playing video elements

Seeking a playing video resets the browser's decoder pipeline, causing
a ~150ms freeze while it re-buffers. During that freeze the monotonic
clock advances, drift grows, and strict sync fires another seek —
creating a perpetual stutter loop (176 seek events / 8s observed on
the apple-presentation composition).

Skip strict and force drift corrections for playing video elements;
only hard sync (>0.5s catastrophic drift) warrants the decoder-reset
cost. Audio elements are unaffected and retain the full correction
tiers.

Also propagate the asset-loading overlay state to the timeline so
controls are disabled during "Preparing preview assets", matching the
existing behavior for the initial composition loading overlay.

* chore: release v0.6.0-alpha.9

* feat(studio): consolidate keyboard shortcuts into single handler

Move all window-level keyboard shortcuts from 4 separate files into
one `handleAppKeyDown` listener in App.tsx:

- Shift+T: toggle timeline (was App.tsx, separate useMountEffect)
- Cmd/Ctrl+Z: undo (was App.tsx, separate useEffect)
- Cmd/Ctrl+Shift+Z: redo (was App.tsx, separate useEffect)
- Cmd/Ctrl+1: sidebar Compositions tab (was LeftSidebar.tsx)
- Cmd/Ctrl+2: sidebar Assets tab (was LeftSidebar.tsx)
- Delete/Backspace: remove selected element (was Timeline.tsx)

LeftSidebar exposes a ref handle for tab switching. Timeline watches
selectedElement becoming null to clean up popover/range UI state.
History hotkey kept as named function for iframe forwarding.

Playback shortcuts (Space, J/K/L, arrows) and caption nudge remain
in their component hooks — tightly coupled to component state.

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

* fix(studio): sidebar tab overflow + hot-reload double-refresh

1. Sidebar tabs: use equal 1fr columns, shorter "Comps" label, truncate
   on overflow, tighter padding. Fixes tabs clipping outside the rounded
   pill at narrow sidebar widths.

2. Hot reload: set domEditSaveTimestampRef before every save-then-refresh
   path (source editor, timeline move/resize/delete, asset drop). The
   file-change watcher already checks this timestamp and suppresses
   echoed events — but source editor saves and timeline operations
   weren't setting it, causing a double refreshKey increment that could
   leave the player in a non-playable state.

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

* fix(studio): delete key removes preview-selected elements

The consolidated keyboard handler only checked selectedElementId
(timeline clips). When a user selected a child element in the
preview via the inspector, selectedElementId was null because
the element didn't correspond to a top-level timeline clip, so
Delete/Backspace did nothing.

Add handleDomEditElementDelete that removes the element referenced
by the current domEditSelection via the remove-element mutation
API. The Delete key handler now falls through from timeline
selection to DOM edit selection.

* fix(studio): remove unused deleteInFlightRef from Timeline

Leftover from moving Delete handling to the consolidated
keyboard handler in App.tsx. Also suppress pre-existing
exhaustive-deps warning on the intentional every-render
selection-change watcher.

* fix(studio): forward all keyboard shortcuts to preview iframe

The consolidated handleAppKeyDown was only added to the parent
window. When focus was inside the preview iframe (after clicking
an element), keydown events didn't reach the parent, so Delete
and other shortcuts didn't fire.

Replace the per-function iframe forwarding (handleTimelineToggleHotkey
only) with the full app-level handler via a ref-stable wrapper.
All app shortcuts (Delete, Undo/Redo, Shift+T, Cmd+1/2) now work
from within the preview iframe.

* fix(core): search inside <template> content when removing elements

linkedom's document.querySelectorAll does not traverse <template>
content. Elements in template-based compositions (like .title-word,
.bullet-text) were invisible to the removal logic, so delete
returned changed: false and the element survived the reload.

Fall back to template.querySelectorAll when the document-level
query returns no matches. Uses template.querySelectorAll directly
(not template.content.querySelectorAll) because removing from
the content DocumentFragment doesn't update the serialized output.

* fix(studio): suppress loading overlay on hot-reload

Only show the composition loading overlay on the first iframe load.
Hot-reloads (source editor save, timeline edits, element delete)
no longer flash the full-screen loading state.

* fix(studio): reorder design panel, fix stroke height, rename Blending

- Move Text section to the top of the panel (before Layout)
- Remove Selection Colors section
- Rename "Blending" to "Transparency"
- Fix stroke Width/Style height mismatch by making SelectField
  use inline label layout matching MetricField

* fix(studio): prevent panel scroll when wheel-adjusting metric inputs

React registers onWheel passively, so preventDefault had no effect
on the parent scroll container. Replace with a native wheel listener
(passive: false) that blocks both default scroll and propagation.

* chore: release v0.6.0-alpha.10

* chore: release v0.6.0-alpha.11

* fix(studio): clean next alpha inspector artifacts

* chore: release v0.6.0-alpha.12

* fix(studio,player,core): eliminate double audio and manifest polling loop (#722)

Three bugs that compound in Studio preview:

1. **Double audio on pause/resume**: syncRuntimeMedia played audio through
   the HTML <audio> element while WebAudioTransport simultaneously played
   the same source through AudioBufferSourceNode. Fixed by passing
   webAudio.isActive() as outputMuted so HTML elements stay muted when
   Web Audio owns playback. Also removed the priorMuted restore in
   stopAll() which raced with the next play cycle.

2. **Manifest polling loop**: applyStudioManualEditsToPreview and
   applyStudioMotionToPreview unconditionally fetched from disk on every
   call, even without forceFromDisk. The runtime posts state messages
   every frame via postMessage, triggering React re-renders that re-invoked
   these functions ~60x/second. Fixed by returning early when no disk read
   is requested, and using refs instead of callbacks in useEffect deps.

3. **Parent proxy double-play**: the player web component created parent-frame
   audio proxies even when the runtime bridge was available, causing two
   audio sources on autoplay-blocked promotion. Fixed by skipping proxy
   creation when _hasRuntimeBridge returns true, and synchronously muting
   iframe media on promotion to close the async race window.

Also fixes pre-existing ResolutionPreset type missing square variants.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): improve font picker and text property controls (#736)

- Line height and letter-spacing: convert from free-text to select with presets
- Font style: remove oblique (browser falls back to italic), keep normal/italic
- Font weight: detect available weights via document.fonts.check(), add labels
- Font source: local fonts matching Google catalog tagged as Google
- Font list: balanced per-source caps prevent any source from being cut off
- Sort order: Google fonts rank before Local so curated fonts appear first

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(studio): inspector visibility, undo/redo blinking, and preview caching

Inspector picks invisible elements when an ancestor has GSAP-set opacity: 0
because CSS opacity is not inherited — getComputedStyle on the child still
returns 1. Walk the ancestor chain in the picker, domEditing, and overlay
visibility checks to catch this.

Also:
- Containers with all-invisible children are no longer selectable
- Selection/hover overlay hides during playback and while loading
- Undo/redo no longer double-refreshes (echo suppression for all file writes)
- Undo/redo reloads iframe in-place instead of recreating the Player,
  preserving shader transition cache
- Preview routes return ETag + Cache-Control headers; composition HTML uses
  project signature for conditional 304, binary assets use mtime+size
- Loading overlay deferred 400ms so cached loads never flash it

* fix(studio): remove timeline inspector buttons, enable manual dragging

Remove the eye icon (inspector) and image icon (thumbnail toggle) from timeline
clips. The timeline layer inspector feature and all supporting code is removed.

Enable manual dragging in the preview by default. Add scrub-to-drag on X/Y/W/H
fields in the design panel. Hide the Radius section when the element has no
visible background. Fix pre-existing ResolutionPreset type for square presets.

* chore: release v0.6.0-alpha.13

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

* fix(studio): add rotation field, inline element drag, fix manifest load regression (#743)

- Add rotation (R) field to geometry row (X, Y, W, H, R) in property panel.
  Goes through manifest via handleDomRotationCommit, resettable with Reset Edits.
- Auto-promote display:inline elements to inline-block when dragged so
  translate works on inline spans.
- Fix regression from polling fix: iframe load now passes readFromDiskFirst
  to load manifest from disk, so Reset Edits finds existing entries.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(studio): decompose App.tsx monolith (4297 → 567 lines) (#741)

* refactor(studio): decompose App.tsx from 4297 to 567 lines

Break the monolithic StudioApp component into focused modules:

Hooks (12 new):
- usePanelLayout: resizable/collapsible panel state
- useFileManager: file tree, CRUD, uploads, derived lists
- useManifestPersistence: manual edit + motion manifest save queue
- useTimelineEditing: clip move/resize/delete/drop handlers
- useDomEditSession: DOM selection, style/text commits, preview interaction
- useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync
- useCaptionDetection: auto-detect caption compositions
- useRenderClipContent: timeline clip thumbnail rendering
- useConsoleErrorCapture: preview iframe console error capture
- useFrameCapture: frame capture download flow
- useLintModal: lint execution and modal state
- useCompositionDimensions: stage-size message listener

Components (6 new):
- AskAgentModal: agent prompt modal
- StudioHeader: toolbar with undo/redo, capture, inspector toggle
- StudioLeftSidebar: file tree + code editor (handles collapsed state)
- StudioPreviewArea: NLELayout + overlays + caption timeline
- StudioRightPanel: Design/Motion/Renders tab panel
- TimelineToolbar: zoom controls + timeline toggle

Utilities (4 new):
- studioHelpers: types, path helpers, DOM utilities
- studioPreviewHelpers: preview pointer/player interaction
- domEditHelpers: selection group algebra
- studioFontHelpers: font injection + @font-face management

Also removes dead timeline layer inspector code (eye icon, thumbnail
toggle, layer panel) that was disabled behind a feature flag.

* feat(studio): add Layer (z-index) field to design panel

Adds a scrub-enabled "Layer" field below the W/H inputs in the Layout
section. Available for all elements regardless of style editing
capability since z-index is fundamental to composition stacking order.

* docs: architecture spec for studio domain contexts, hook split, and file-size lint

* docs: implementation plan for studio contexts, hook split, and file-size lint

* refactor(studio): consolidate duplicate helpers in useDomEditSession

Remove ~370 lines of helper functions that were copied into the hook
instead of imported. All removed functions already exist in the
canonical utility files (studioHelpers, studioFontHelpers,
studioPreviewHelpers, domEditHelpers). Also removes the duplicate
local type definitions for RightPanelTab, AgentModalAnchorPoint, and
PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl,
importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport).

Temporarily excludes useDomEditSession.ts from the 500 LOC file-size
check until Tasks 3-5 split it into focused hooks.

* refactor(studio): extract useDomSelection from useDomEditSession

* refactor(studio): extract useAskAgentModal from useDomEditSession

* refactor(studio): extract usePreviewInteraction from useDomEditSession

* refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator

Split the 897-line useDomEditSession into focused hooks:
- useDomEditCommits (439 LOC): manifest commits (path offset, box size,
  rotation, manual edits reset, motion), persist operations, element delete,
  font asset resolution
- useDomEditTextCommits (329 LOC): style/text/text-field commits
- useDomEditSession (339 LOC): thin orchestrator wiring selection, agent
  modal, preview interaction, and commit hooks

All files now under 500 LOC limit. Removed the temporary lefthook
filesize exclusion for useDomEditSession.

* feat(studio): add 4 domain contexts (PanelLayout, FileManager, DomEdit, Studio)

Create context providers that wrap hook return values for prop-drilling
elimination. Each context destructures and reconstructs the value inside
useMemo so exhaustive-deps is satisfied and re-renders are minimized.

Not yet wired into App.tsx — that comes in a follow-up.

* refactor(studio): wire domain contexts, eliminate prop drilling in 4 components

Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and
DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar,
StudioPreviewArea, and StudioRightPanel to consume contexts instead
of props.

Prop counts reduced:
- StudioHeader: 13 -> 6
- StudioLeftSidebar: 19 -> 4
- StudioPreviewArea: 37 -> 11
- StudioRightPanel: 39 -> 3

Net: -118 lines, 108 props removed from call sites.

* chore: upgrade to React 19

Upgrade react and react-dom from 18.3 to 19.2.6 across the workspace.
Add resolutions/overrides in root package.json to prevent peer
dependency pins (e.g. @phosphor-icons/react) from pulling React 18.
Regenerate bun.lock.

This enables the React 19 context syntax (<Context value={...}>)
used by the new domain contexts.

* fix(studio): refresh preview after z-index change so stacking updates visually

* fix(studio): remove duplicate duration override causing oscillation

The timeline message handler set the duration twice: once via
processTimelineMessage and once via a raw durationInFrames override.
When drilled into a sub-composition, these could disagree, causing
the duration to oscillate after element deletion.

* fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs

Two changes to fix duration oscillation after deleting a timeline clip:

1. Replace setRefreshKey (full Player remount) with in-place
   iframe.contentWindow.location.reload() after deleting a clip.
   The full remount triggered a chaotic re-probing cycle with multiple
   duration sources (adapter, manifest, postMessage) fighting each
   other, causing the timeline to oscillate between durations.
   In-place reload preserves the Player web component and its state.

2. Remove window.confirm dialogs from both timeline clip delete and
   DOM element delete. Undo is available so the confirmation adds
   friction without value.

* chore: gitignore docs/superpowers

* feat(studio): add favicon

* perf(studio): skip no-op state updates in timeline sync

syncTimelineElements was called 60+ times per page load, each time
triggering setElements/setDuration/setTimelineReady even when nothing
changed. This caused massive re-render churn and memory usage.

Add early-return guards to skip updates when values haven't changed.
Also fixes the duration oscillation after element delete.

* refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules

The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit.
Split into cohesive modules by responsibility:

- propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants
- propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField,
  SliderControl, SegmentedControl, SelectField, Section
- propertyPanelColor.tsx (371) — ColorField, ColorSlider
- propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers
- propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers
- propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls
- propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill)
- PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers

All re-exports from PropertyPanel.tsx preserved for backwards compatibility.
No behavioral changes — pure structural split.

* fix(studio): use in-place iframe reload for all timeline operations

Replace setRefreshKey with in-place iframe reload for move, resize,
and asset drop — matching delete which was already fixed. Prevents
the Player remount probe cycle that causes duration oscillation.

* perf(studio): replace 5s polling loop with event-driven adapter init

The Player's onIframeLoad used a setInterval polling loop (25 attempts
× 200ms = 5 seconds) to detect when the runtime's __player/__timeline
globals appeared. Each poll that missed triggered wasted work, and
multiple duration sources fighting during the probe cycle caused
oscillation bugs.

Replace with event-driven initialization:
1. Fast path: try initializeAdapter() immediately (works for in-place
   reloads where the adapter is already present)
2. If not ready, listen for the runtime's "state"/"timeline" postMessage
   signals and initialize on the first one
3. Single 5s timeout as safety net (replaces 25 interval ticks)

This eliminates the polling overhead, reduces setDuration/setElements
calls to exactly 1 per load, and makes the Player responsive within
one frame of the runtime being ready instead of up to 200ms later.

* fix(studio): prevent duration oscillation after element delete

Two fixes for the duration display oscillating between sub-composition
and master durations after deleting an element in the preview:

1. Clear store elements before iframe reload in handleDomEditElementDelete.
   Without this, stale pre-delete elements remain in the store and cause
   mergeTimelineElementsPreservingDowngrades to alternate between REPLACE
   and PRESERVE modes as the element count fluctuates.

2. Add 500ms cooldown on enrichMissingCompositions after timeline messages.
   The "state" handler was calling enrichMissingCompositions every ~80ms,
   which added extra elements from GSAP timelines. These fought with the
   authoritative element list from "timeline" messages (~333ms), creating
   a feedback loop where element count oscillated and triggered alternating
   merge strategies with different durations.

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

* fix(studio): single reloadPreview as source of truth for preview refresh

Create reloadPreview() in App.tsx that encapsulates the correct
behavior (in-place iframe reload with setRefreshKey fallback). Pass it
as the sole refresh mechanism to hooks, removing direct setRefreshKey
access from useTimelineEditing and useDomEditCommits.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(studio): decompose App.tsx from 4297 to 567 lines

Break the monolithic StudioApp component into focused modules:

Hooks (12 new):
- usePanelLayout: resizable/collapsible panel state
- useFileManager: file tree, CRUD, uploads, derived lists
- useManifestPersistence: manual edit + motion manifest save queue
- useTimelineEditing: clip move/resize/delete/drop handlers
- useDomEditSession: DOM selection, style/text commits, preview interaction
- useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync
- useCaptionDetection: auto-detect caption compositions
- useRenderClipContent: timeline clip thumbnail rendering
- useConsoleErrorCapture: preview iframe console error capture
- useFrameCapture: frame capture download flow
- useLintModal: lint execution and modal state
- useCompositionDimensions: stage-size message listener

Components (6 new):
- AskAgentModal: agent prompt modal
- StudioHeader: toolbar with undo/redo, capture, inspector toggle
- StudioLeftSidebar: file tree + code editor (handles collapsed state)
- StudioPreviewArea: NLELayout + overlays + caption timeline
- StudioRightPanel: Design/Motion/Renders tab panel
- TimelineToolbar: zoom controls + timeline toggle

Utilities (4 new):
- studioHelpers: types, path helpers, DOM utilities
- studioPreviewHelpers: preview pointer/player interaction
- domEditHelpers: selection group algebra
- studioFontHelpers: font injection + @font-face management

Also removes dead timeline layer inspector code (eye icon, thumbnail
toggle, layer panel) that was disabled behind a feature flag.

* docs: architecture spec for studio domain contexts, hook split, and file-size lint

* docs: implementation plan for studio contexts, hook split, and file-size lint

* refactor(studio): consolidate duplicate helpers in useDomEditSession

Remove ~370 lines of helper functions that were copied into the hook
instead of imported. All removed functions already exist in the
canonical utility files (studioHelpers, studioFontHelpers,
studioPreviewHelpers, domEditHelpers). Also removes the duplicate
local type definitions for RightPanelTab, AgentModalAnchorPoint, and
PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl,
importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport).

Temporarily excludes useDomEditSession.ts from the 500 LOC file-size
check until Tasks 3-5 split it into focused hooks.

* refactor(studio): extract useDomSelection from useDomEditSession

* refactor(studio): extract useAskAgentModal from useDomEditSession

* refactor(studio): extract usePreviewInteraction from useDomEditSession

* refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator

Split the 897-line useDomEditSession into focused hooks:
- useDomEditCommits (439 LOC): manifest commits (path offset, box size,
  rotation, manual edits reset, motion), persist operations, element delete,
  font asset resolution
- useDomEditTextCommits (329 LOC): style/text/text-field commits
- useDomEditSession (339 LOC): thin orchestrator wiring selection, agent
  modal, preview interaction, and commit hooks

All files now under 500 LOC limit. Removed the temporary lefthook
filesize exclusion for useDomEditSession.

* refactor(studio): wire domain contexts, eliminate prop drilling in 4 components

Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and
DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar,
StudioPreviewArea, and StudioRightPanel to consume contexts instead
of props.

Prop counts reduced:
- StudioHeader: 13 -> 6
- StudioLeftSidebar: 19 -> 4
- StudioPreviewArea: 37 -> 11
- StudioRightPanel: 39 -> 3

Net: -118 lines, 108 props removed from call sites.

* fix(studio): refresh preview after z-index change so stacking updates visually

* fix(studio): remove duplicate duration override causing oscillation

The timeline message handler set the duration twice: once via
processTimelineMessage and once via a raw durationInFrames override.
When drilled into a sub-composition, these could disagree, causing
the duration to oscillate after element deletion.

* fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs

Two changes to fix duration oscillation after deleting a timeline clip:

1. Replace setRefreshKey (full Player remount) with in-place
   iframe.contentWindow.location.reload() after deleting a clip.
   The full remount triggered a chaotic re-probing cycle with multiple
   duration sources (adapter, manifest, postMessage) fighting each
   other, causing the timeline to oscillate between durations.
   In-place reload preserves the Player web component and its state.

2. Remove window.confirm dialogs from both timeline clip delete and
   DOM element delete. Undo is available so the confirmation adds
   friction without value.

* chore: gitignore docs/superpowers

* perf(studio): skip no-op state updates in timeline sync

syncTimelineElements was called 60+ times per page load, each time
triggering setElements/setDuration/setTimelineReady even when nothing
changed. This caused massive re-render churn and memory usage.

Add early-return guards to skip updates when values haven't changed.
Also fixes the duration oscillation after element delete.

* refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules

The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit.
Split into cohesive modules by responsibility:

- propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants
- propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField,
  SliderControl, SegmentedControl, SelectField, Section
- propertyPanelColor.tsx (371) — ColorField, ColorSlider
- propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers
- propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers
- propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls
- propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill)
- PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers

All re-exports from PropertyPanel.tsx preserved for backwards compatibility.
No behavioral changes — pure structural split.

* fix(studio): use in-place iframe reload for all timeline operations

Replace setRefreshKey with in-place iframe reload for move, resize,
and asset drop — matching delete which was already fixed. Prevents
the Player remount probe cycle that causes duration oscillation.

* perf(studio): replace 5s polling loop with event-driven adapter init

The Player's onIframeLoad used a setInterval polling loop (25 attempts
× 200ms = 5 seconds) to detect when the runtime's __player/__timeline
globals appeared. Each poll that missed triggered wasted work, and
multiple duration sources fighting during the probe cycle caused
oscillation bugs.

Replace with event-driven initialization:
1. Fast path: try initializeAdapter() immediately (works for in-place
   reloads where the adapter is already present)
2. If not ready, listen for the runtime's "state"/"timeline" postMessage
   signals and initialize on the first one
3. Single 5s timeout as safety net (replaces 25 interval ticks)

This eliminates the polling overhead, reduces setDuration/setElements
calls to exactly 1 per load, and makes the Player responsive within
one frame of the runtime being ready instead of up to 200ms later.

* fix(studio): prevent duration oscillation after element delete

Two fixes for the duration display oscillating between sub-composition
and master durations after deleting an element in the preview:

1. Clear store elements before iframe reload in handleDomEditElementDelete.
   Without this, stale pre-delete elements remain in the store and cause
   mergeTimelineElementsPreservingDowngrades to alternate between REPLACE
   and PRESERVE modes as the element count fluctuates.

2. Add 500ms cooldown on enrichMissingCompositions after timeline messages.
   The "state" handler was calling enrichMissingCompositions every ~80ms,
   which added extra elements from GSAP timelines. These fought with the
   authoritative element list from "timeline" messages (~333ms), creating
   a feedback loop where element count oscillated and triggered alternating
   merge strategies with different durations.

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

* fix(studio): single reloadPreview as source of truth for preview refresh

Create reloadPreview() in App.tsx that encapsulates the correct
behavior (in-place iframe reload with setRefreshKey fallback). Pass it
as the sole refresh mechanism to hooks, removing direct setRefreshKey
access from useTimelineEditing and useDomEditCommits.

* fix: resolve lint errors from rebase (unused imports, duplicate declarations)

* fix: prefix unused probeResult variable

* fix: restore renderOrchestrator.ts from origin/next (rebase conflict artifact)

* fix: resolve rebase conflicts by using main's producer and next's studio/player

* fix: restore rebase-conflicted files from origin/next

* fix: use 'load' instead of 'networkidle0' for Puppeteer waitUntil (type compatibility)

* fix: restore webAudioTransport.ts from main (test compatibility)

---------

Co-authored-by: Vance Ingalls <vance@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 20:18:52 +02:00
Miguel Ángel 8986ab2739 fix(ci): harden GitHub Actions workflows against supply chain attacks (#740) 2026-05-12 07:06:06 +02:00
Miguel Ángel 4988b42362 ci: relax CLI smoke validate timeout 2026-04-28 16:18:46 -04:00
Miguel Ángel 84d58011f1 ci: address CLI smoke review feedback 2026-04-28 11:02:27 -04:00
Miguel Ángel 82ce413fd9 ci: install ffmpeg in CLI smoke job 2026-04-28 10:57:50 -04:00
Miguel Ángel 9c7983211a fix: validate CLI smoke paths and warn on oversized compositions 2026-04-28 10:57:50 -04:00
renovate[bot] 745b877796 chore(deps): pin dependencies 2026-04-27 23:51:52 +00:00
Miguel Ángel 267ffd3fca fix(engine,producer): preserve template-wrapped sub-composition media offsets (#476)
## Problem

Template-wrapped sub-compositions could still lose correct parent timing during render in more than one place.

In the validated repros, a host sub-composition starting after the intro (and in one follow-up repro, starting at `20s` after earlier compositions) contained scene-local media inside it. On the broken paths:

- template-wrapped media could be missed during compile and scheduled at raw scene-local time
- already-correct first-pass offsets could be clobbered during `recompileWithResolutions()`
- even after those two fixes, the browser-metadata reconcile step in `executeRenderJob()` could still overwrite a compiled global `end` with a scene-local `data-end` from the inlined DOM, clipping the tail off late-start sub-composition media

## What this fixes

### Template-wrapped media discovery

- `parseVideoElements`, `parseImageElements`, and `parseAudioElements` now unwrap a single top-level `<template>` wrapper before scraping media
- the unwrap helper is DOM-based, not regex-based, so it avoids the CodeQL backtracking warning and only unwraps the exact single-wrapper shape we want
- multiple sibling templates or other top-level content are left untouched instead of being rewritten heuristically

### Offset preservation after duration resolution

- `recompileWithResolutions()` now preserves the first-pass sub-composition media arrays when the already-inlined HTML no longer contains `[data-composition-src]` hosts
- that prevents correctly offset media metadata from being overwritten by scene-local media parsed from the merged DOM

### Browser metadata reconciliation in the compiled time origin

- browser-discovered media can still report scene-local `data-start` / `data-end` from the merged DOM after inlining
- the producer now reprojects browser `end` values into the compiled element's time origin before reconciling them back into `composition.videos` / `composition.audios`
- this prevents late-start sub-composition media from getting truncated back to a scene-local end during the probe phase

### Regression coverage

- adds focused engine tests for the template unwrap helper
- adds producer regression coverage for both the initial compile path and the post-inline `recompileWithResolutions()` path
- adds producer regression coverage for late-start host compositions (`t≈20`) with scene-local media inside them
- adds producer unit coverage for the browser-end reprojection helper used by the reconcile path

## Root cause

There were three distinct renderer failures behind the bug:

### 1. Template contents were invisible to the media scrapers

`parseSubCompositions()` reads raw sub-composition HTML and applies the host offset to discovered media. But the engine media helpers were querying the parsed document directly, and linkedom follows browser semantics here: top-level `<template>` contents live in a `DocumentFragment`, so `querySelectorAll()` never saw those `<video>` / `<audio>` / `<img>` nodes.

That meant template-wrapped sub-compositions could silently produce zero discovered media during the first pass.

### 2. The duration-resolution recompile could clobber already-correct offsets

After the browser resolves composition durations, `recompileWithResolutions()` reparses the already-inlined HTML. By that point the original `[data-composition-src]` hosts are gone, so `parseSubCompositions()` legitimately returns no nested media.

The old code still rebuilt the deduped media arrays from the merged DOM, which let scene-local media parsed from the inlined HTML overwrite the correctly offset first-pass metadata.

### 3. The browser probe reconcile path mixed two timing coordinate systems

`discoverMediaFromBrowser()` reads `data-start` / `data-end` directly from the live DOM after sub-compositions are already inlined. For nested media, those attributes can still be scene-local even though the compiled metadata has already been offset into the parent host timeline.

The old reconcile path compared those values directly and overwrote `existing.end` whenever the numbers differed. For a late-start sub-composition, that could replace a correct global end like `25.5` with a scene-local end like `5.5`, cutting the clip off during render.

## Verification

### Local checks

- `bun test packages/engine/src/utils/htmlTemplate.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bunx vitest run packages/producer/src/services/renderOrchestrator.test.ts`
- `bun run --filter @hyperframes/engine test`
- `bun run --filter @hyperframes/engine typecheck`
- `bun run --filter @hyperframes/producer typecheck`
- `bunx oxlint packages/engine/src/utils/htmlTemplate.ts packages/engine/src/utils/htmlTemplate.test.ts packages/producer/src/services/renderOrchestrator.ts packages/producer/src/services/renderOrchestrator.test.ts packages/producer/src/services/htmlCompiler.test.ts`
- `bunx oxfmt --check packages/engine/src/utils/htmlTemplate.ts packages/engine/src/utils/htmlTemplate.test.ts`
- `bun run build:producer`

### Render / browser verification

Verified against two local repros:

1. **Early offset repro**
   - host starts at `2s`
   - child media is scene-local `0-4s`
   - compiled render summary keeps the child video/audio at `start: 2`
   - browser verification via `agent-browser` confirmed the `2.2s` frame still shows the child clip active in the host timeline

2. **Late offset repro**
   - earlier compositions run first, then the target host starts at `20s`
   - child media starts scene-local at `1.5s` and should remain visible through `24.5s`
   - compiled render summary keeps the child video/audio at `start: 21.5`, `end: 25.5`
   - browser verification via `agent-browser` confirmed the `24.5s` frame still shows the late clip visible, which is the exact tail-clipping case the old reconcile path could break

## Notes

- the `/tmp/hf-pr475-repro` and `/tmp/hf-pr476-late-offset-repro` projects plus their browser-proof artifacts are verification-only and are not part of this PR
- this PR stays narrowly scoped to sub-composition media timing across compile, recompile, and browser probe reconciliation; it does not broaden into general sub-composition HTML normalization beyond the single-wrapper case
2026-04-24 19:00:42 +02:00
Miguel Ángel 072814e65c fix(core,cli,ci): harden runtime resolution + inline constant + smoke test (#458)
Guard buildHyperframesRuntimeScript() against missing entry.ts so it
returns null instead of crashing with esbuild stderr output. Add
getHyperframeRuntimeScript() that returns the pre-built IIFE as a
baked-in string constant — no esbuild, no file I/O, no import.meta.url.

Consolidate CLI runtime source resolution into a single module with
a clear priority chain: esbuild from source (dev) → inlined constant
(production) → pre-built artifact file (fallback).

Add CI smoke test that npm-packs the CLI, installs globally, runs
hyperframes preview, and asserts no stderr errors + runtime endpoint
returns JS.

Bump version to 0.4.16.
2026-04-23 21:44:26 +02:00
Vance Ingalls 3089c8ee3a build(lfs): track tests/*/src/*.png via Git LFS (#376)
## Summary

Track `tests/*/src/*.png` via Git LFS to mirror the existing policy for golden videos and `.mp4` fixtures.

## Why

`Chunk 11C` of `plans/hdr-followups.md`. Without this rule, regression suites that grow PNG fixtures over time would bloat the working-tree history and slow shallow clones.

## What changed

- `.gitattributes`: add `tests/*/src/*.png` to the LFS-tracked patterns.
- Migrates the six existing PNG fixtures (1.6 MB combined: `hdr-photo-pq.png` plus `heygen-promo-preview-assets/` screenshots) onto LFS in the same commit so the rule applies retroactively.

## Test plan

- [x] `git lfs ls-files` includes the HDR PNG fixtures after commit.
- [x] Working tree size for these files goes from 1.6 MB to 6 × ~130 B LFS pointers.

## Stack

Chunk 11C of `plans/hdr-followups.md`. Independent of all code changes.
2026-04-23 09:31:48 -07:00