Commit Graph
100 Commits
Author SHA1 Message Date
Miguel Ángel 6b5b4cb988 feat(studio): make agent edits live and explicit (#3581) 2026-09-02 01:11:13 -04:00
Miguel Ángel 6cbe3fbe90 chore: release v0.8.23 (#3586) 2026-09-01 13:58:14 -04:00
Miguel ÁngelandJames 38e356fba4 chore: release v0.8.22 (#3575)
* chore: release v0.8.22

* docs: include encoder retry in v0.8.22 notes

---------

Co-authored-by: James <james.russo@heygen.com>
2026-08-31 22:54:13 -04:00
Miguel Ángel f3099dcb27 chore: release v0.8.21 (#3570) 2026-08-31 15:16:22 -04:00
2d6b055f31 feat(studio): let an agent author motion (#3520)
* feat(studio): let an agent drive Studio's selection and playhead

Adds `studio_select` and `studio_seek`, so an agent and the human are looking
at the same element and the same instant. Selecting reveals the inspector,
exactly as a click does, which is what makes the agent's move visible.

Selection is shared state, not a per-call argument, and that is forced rather
than chosen. Most of Studio's edit handlers read the ambient React selection,
and `applyDomSelection` only schedules a state update, so selecting and
committing inside ONE call would write to whatever was selected before. Two
tool calls are separated by a render, so the contract is select first, then
act. That is also how a human works: click, then type.

`studio_seek` uses `requestSeek`, not `setCurrentTime`. The latter only moves
the timeline's displayed number and leaves the composition where it was.

Two things the tools refuse to fake:

Seek does not clamp. `seek()` already clamps against the adapter's duration,
which can differ from the store's, and clamping again would give that
invariant two owners that can disagree. The tool reports where the playhead
actually landed instead, read back afterwards.

`requestSeek` is fire-and-forget, so it cannot report that no adapter was
mounted to receive it. The tool compares the playhead before and after and
fails rather than claiming a seek that never happened.

Select separates three failures that a single message would have merged: the
preview is not mounted yet (wait), no element matches the handle (re-read),
and the element cannot be selected (try a neighbour). The agent's next move
differs for each, so collapsing them would cost it a round trip or a retry
loop.

* feat(studio): give an agent eyes with studio_frame

Renders the composition to a PNG at a given time and returns the URL. This is
what turns the tool set from a remote control into a loop: author a change,
capture the instant it affects, look, adjust. No agent can judge motion from
source, because "what does this look like at 2.4 seconds" is not a question a
file answers.

Reuses Studio's existing capture endpoint via `buildFrameCaptureUrl` rather
than inventing a second one.

Two things this does not fake:

It reports the time the playhead LANDED on, not the time requested. The player
clamps, so those differ at the ends, and attaching the wrong time to a frame is
how an agent draws a confident wrong conclusion about motion.

It waits before capturing, by default 150ms. The frame is rendered from the
file on disk, and the render cache is cleared by a file watcher with a 40ms
write-stability threshold, so a capture that beats the watcher renders the
PRE-edit composition. That exact staleness was a real bug here once. An agent
reading a stale frame as "my edit failed" would thrash, so the wait is on by
default, `settleMs` makes it tunable, and the tool description names the
failure rather than leaving it to be rediscovered.

It probes with HEAD before returning, so a URL that 404s comes back as a
failure with a hint instead of as a link the agent cannot render.

* feat(studio): add studio_inspect, so an agent reads before it writes

Everything about one element in one call: resolved styles, text fields, box,
data attributes, GSAP animations, and what the element will and will not
accept.

The point is to prevent a failed write rather than to satisfy curiosity.
`can.reasonIfDisabled` is passed through verbatim from Studio's own
capabilities, so an agent that reads first should never attempt an edit the
element would refuse.

Three things it refuses to get wrong:

Animations are reported ONLY for the current selection, because that is the
only element Studio parses them for. Attributing them to any other element
would be reporting the wrong element's motion, which is worse than reporting
none. When a handle names something else the field is empty and
`animationEditingBlocked` says why.

`animationEditingBlocked` also carries the two states where animation editing
is off entirely, multiple timelines and an unsupported timeline pattern. Both
live on the selection context. Learning them from a read costs one call;
learning them from a failed write costs a retry loop.

Inspecting a handle does NOT change what is selected. It is a read, and
stealing the human's selection would be a side effect they did not ask for.
There is a test asserting `applySelection` is never called.

Nothing selected and no handle given is a failure, not an empty result. An
empty result would assert "this element has nothing", which is a different and
false claim.

* feat(studio): let an agent edit text and styles, guarded

The first tools that change the composition. Both act on the current
selection and take no handle, which is forced rather than chosen: the
handlers read the ambient React selection, and `applyDomSelection` only
schedules a state update, so selecting and committing inside one call would
write to whatever was selected before. Select first, then edit.

Also plumbs the write-blocked state, which was the blocker for shipping any
write at all. `domEditSaveQueuePaused` and the external-file conflict both
lived on App and were unreachable from the tool surface, so `canWrite` was
optimistic and a comment said so. They now derive into a single
`writeBlockedReason` on the shell context: one field, one owner, conflict
taking precedence because resolving it is what unblocks the queue.

That guard matters more than it looks. Both states are BANNERS in Studio with
no lock behind them, so nothing else was stopping a programmatic write from
landing on top of a conflict the user had been asked to adjudicate.

Three things the tools refuse to fake:

They check the outcome, not the absence of a throw. Studio has several paths
where a failed commit resolves anyway, so awaiting the handler proves nothing.
The tagged outcome added earlier is what proves the write landed.

A partial style result is reported as partial. `handleDomStyleCommit` is one
property per call, so N properties are N commits; the result carries `applied`
and `rejected` maps rather than a single boolean that would have to pick a
side.

Style commits run sequentially, never concurrently. Two commits racing through
Studio's client-side read-modify-write can record undo entries that both claim
the same starting content. There is a test that measures concurrency rather
than trusting the loop.

Every decline reason maps to a hint naming what to do instead, so a refusal
routes the agent rather than just stopping it.

* feat(studio): move, resize and rotate, verified by reading back

`studio_transform` does what a drag does, and then checks. The box in the
result is READ BACK after the write, never echoed from the request, and
`applied` lists what actually took effect.

That is not belt-and-braces. The plan for this unit said to re-derive the
geometry handlers' behaviour rather than trust any description of them, and
doing that turned up three different behaviours behind one interface.

The handlers on `DomEditActionsValue` are the GSAP-AWARE wrappers, aliased in
`useDomEditSession.ts:534-538`, not the CSS ones in `useDomGeometryCommits.ts`
that an earlier note in this workstream described.

`handleGsapAwarePathOffsetCommit` and `handleGsapAwareRotationCommit` are
`if (gsapCommitMutation) { ...intercept... }` with no else branch. Their own
comments say the absence is deliberate: position and rotation are written as
GSAP code and there is no CSS fallback to write to. So they can return having
done nothing.

`handleGsapAwareBoxSizeCommit` is not like the other two. It runs through
`runGestureTransaction` with separate scale and width/height routes, so resize
works more generally.

Reading back is what turns that middle case from a silent lie into a reported
one. A move that did nothing comes back in `unchanged` with a reason.

Three smaller decisions:

Operations re-read between each other, so a move is judged against the box
AFTER a resize in the same call. Comparing against the original would credit
the resize's change to the move.

Rotation is reported as dispatched, not verified. `rotate` is an individual
transform property and does not appear in the computed transform, so there is
no honest box-derived signal, and claiming one would be worse than saying so.

x pairs with y and width pairs with height. Accepting one alone would mean
inventing the other from the current value, which moves the element somewhere
the caller did not ask for. The pairing rule and its minimum live in one
`parsePair` helper rather than as four separate branches.

* feat(studio): let an agent author motion

Four tools: add an animation, change its duration/ease/position, add a
keyframe, delete it. This is the capability that makes the tool set worth
having, because motion is the one thing an agent cannot judge or author from
source.

These are deliberately less confident than the rest of the set, and the
reason is the handlers underneath them:

`handleGsapAddAnimation(method)` takes only a method. Its insert position
comes from the live playhead, not the caller, and the call is `void ...catch()`
so it returns nothing.

`handleGsapAddKeyframeBatch` returns a promise but catches its own failure, so
awaiting proves the call finished, not that it landed.

`handleGsapDeleteAnimation` discards its promise entirely.

`handleGsapUpdateMeta` is the one honest signal. It returns a boolean.

U8 handled the same problem by reading the result back. That does not work
here: the animation list comes from React state that only refreshes on a
render, and no render happens inside one tool call. Rather than fake a
verification with a frame-timer, these report what was DISPATCHED and the
descriptions tell the agent to call studio_inspect to see the result. Saying
"I asked for this" is honest; saying "this happened" would not be.

Three consequences worth stating:

`studio_add_animation` takes no position. The handler reads the playhead, so
accepting one would report a number that had no effect. It reports where the
playhead actually was and tells the agent to seek first.

`studio_update_animation` rules out the no-selection case BEFORE dispatch. The
handler answers `false` for both "nothing selected" and "the write failed", so
eliminating one is what makes the other legible.

Keyframe percent and properties are validated in the tool, because nothing in
the platform checks input against the declared schema.

* feat(studio): add studio_inspect, so an agent reads before it writes (#3517)

Everything about one element in one call: resolved styles, text fields, box,
data attributes, GSAP animations, and what the element will and will not
accept.

The point is to prevent a failed write rather than to satisfy curiosity.
`can.reasonIfDisabled` is passed through verbatim from Studio's own
capabilities, so an agent that reads first should never attempt an edit the
element would refuse.

Three things it refuses to get wrong:

Animations are reported ONLY for the current selection, because that is the
only element Studio parses them for. Attributing them to any other element
would be reporting the wrong element's motion, which is worse than reporting
none. When a handle names something else the field is empty and
`animationEditingBlocked` says why.

`animationEditingBlocked` also carries the two states where animation editing
is off entirely, multiple timelines and an unsupported timeline pattern. Both
live on the selection context. Learning them from a read costs one call;
learning them from a failed write costs a retry loop.

Inspecting a handle does NOT change what is selected. It is a read, and
stealing the human's selection would be a side effect they did not ask for.
There is a test asserting `applySelection` is never called.

Nothing selected and no handle given is a failure, not an empty result. An
empty result would assert "this element has nothing", which is a different and
false claim.

* feat(studio): move, resize and rotate, verified by reading back (#3519)

`studio_transform` does what a drag does, and then checks. The box in the
result is READ BACK after the write, never echoed from the request, and
`applied` lists what actually took effect.

That is not belt-and-braces. The plan for this unit said to re-derive the
geometry handlers' behaviour rather than trust any description of them, and
doing that turned up three different behaviours behind one interface.

The handlers on `DomEditActionsValue` are the GSAP-AWARE wrappers, aliased in
`useDomEditSession.ts:534-538`, not the CSS ones in `useDomGeometryCommits.ts`
that an earlier note in this workstream described.

`handleGsapAwarePathOffsetCommit` and `handleGsapAwareRotationCommit` are
`if (gsapCommitMutation) { ...intercept... }` with no else branch. Their own
comments say the absence is deliberate: position and rotation are written as
GSAP code and there is no CSS fallback to write to. So they can return having
done nothing.

`handleGsapAwareBoxSizeCommit` is not like the other two. It runs through
`runGestureTransaction` with separate scale and width/height routes, so resize
works more generally.

Reading back is what turns that middle case from a silent lie into a reported
one. A move that did nothing comes back in `unchanged` with a reason.

Three smaller decisions:

Operations re-read between each other, so a move is judged against the box
AFTER a resize in the same call. Comparing against the original would credit
the resize's change to the move.

Rotation is reported as dispatched, not verified. `rotate` is an individual
transform property and does not appear in the computed transform, so there is
no honest box-derived signal, and claiming one would be worse than saying so.

x pairs with y and width pairs with height. Accepting one alone would mean
inventing the other from the current value, which moves the element somewhere
the caller did not ask for. The pairing rule and its minimum live in one
`parsePair` helper rather than as four separate branches.

* docs: document Studio's WebMCP agent tools, proven end-to-end in a browser (#3521)

* docs: document Studio's WebMCP agent tools

Adds `guides/webmcp`, under Developers > Agent setup.

Its first job is to defuse a name collision. `guides/mcp` already exists and
covers HeyGen's HOSTED MCP connector, which builds a video from a chat. This
page is about an agent working inside Studio on a composition already open in
front of you. Different feature, confusingly similar name, so the page says
what it is not before it says what it is.

Written to DOCS_GUIDELINES: one-sentence intro, outcome before implementation,
real values rather than placeholders, and three callouts.

The three things a reader most needs are the ones easiest to get wrong:

The API is `document.modelContext`, not `navigator.modelContext`. Most
published examples use the second, which is a polyfill compatibility shim
rather than a spec member, so feature-detecting it misleads.

Select first, then edit. Most editing tools act on the current selection, and
an agent that skips it gets an error rather than a wrong-element write.

Leave Studio visible. Some of Studio's write paths report failure through a
toast rather than a return value, so the human is the one who sees it. That is
a real property of the co-pilot design, not a nicety, so the page says it
plainly.

Verified with `npx mint validate` and `npx mint broken-links --check-redirects`,
both passing.

* fix(studio): target the text field that exists, not one named self

Found by running the tools end to end in a browser, which is the only way it
could have been found: the unit tests mock `setText`, so they never crossed the
boundary where this breaks.

An element's text usually lives in a CHILD field, keyed like `self:0:h1` or
`child:0:h1`. `studio_set_text` passed no field key, so
`buildNextDomTextFields` planned zero operations, the request went out with an
empty patch, and the server answered:

  POST /api/projects/<id>/file-mutations/patch-element
  -> 400 {"error":"target and operations required"}

Which surfaced as `persist-failed`. The tool was telling the truth, so the
reporting work in the earlier PRs did its job, but the failure looked like a
server problem and was not.

The tool now resolves the field: the one the caller named, or the element's
single field when it has exactly one. An element with several fields is asked
to name one; an element with none is reported blocked. Naming a field the
element does not have is rejected with the list of the ones it does have,
rather than silently writing nowhere.

Four regression tests, including the exact `child:0:h1` shape that failed. One
existing assertion changed: it expected the field to be `undefined`, which is
precisely the bug, so it now expects the resolved key.

Also documents two things the browser run surfaced, both real and neither a
defect: registration is asynchronous, so a caller reading `getTools()` too
early sees a partial list; and the tools that act on the current selection need
a render between the select and the edit, which a real agent gets for free
because its calls arrive as separate messages.

* docs: give the agent-tools kill switch instructions that work

The page told readers to set agentToolsEnabled in Studio's preferences.
Nothing writes that flag: it is read in useStudioAgentTools and parsed in
studioUiPreferences, but there is no settings UI and no toggle, so the
instruction could not be followed. Replace it with the localStorage write
that actually flips it, and spell out the merge, since overwriting the key
drops every other stored preference.

* docs: do not promise a per-call permission prompt we have not verified

The page said the browser asks before any agent calls a tool. Prompt
granularity is browser-specific and unsettled during the origin trial, and
we have not observed it on the native path. Say what holds, that access is
gated, and name the part that is still moving.

* fix(studio): re-apply WebMCP test polyfill fix (#3532 regression)

The squash merge of #3518 re-introduced the old assertion that
document.modelContext is absent. The polyfill from #3514 installs it
as a fallback — that is expected behavior.

Same fix as #3532: remove the assertion, keep the boot-cleanly contract.

---------

Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-31 14:45:09 -04:00
f84b4c23dc feat(studio): let an agent edit text and styles, guarded (#3518)
* feat(studio): let an agent drive Studio's selection and playhead

Adds `studio_select` and `studio_seek`, so an agent and the human are looking
at the same element and the same instant. Selecting reveals the inspector,
exactly as a click does, which is what makes the agent's move visible.

Selection is shared state, not a per-call argument, and that is forced rather
than chosen. Most of Studio's edit handlers read the ambient React selection,
and `applyDomSelection` only schedules a state update, so selecting and
committing inside ONE call would write to whatever was selected before. Two
tool calls are separated by a render, so the contract is select first, then
act. That is also how a human works: click, then type.

`studio_seek` uses `requestSeek`, not `setCurrentTime`. The latter only moves
the timeline's displayed number and leaves the composition where it was.

Two things the tools refuse to fake:

Seek does not clamp. `seek()` already clamps against the adapter's duration,
which can differ from the store's, and clamping again would give that
invariant two owners that can disagree. The tool reports where the playhead
actually landed instead, read back afterwards.

`requestSeek` is fire-and-forget, so it cannot report that no adapter was
mounted to receive it. The tool compares the playhead before and after and
fails rather than claiming a seek that never happened.

Select separates three failures that a single message would have merged: the
preview is not mounted yet (wait), no element matches the handle (re-read),
and the element cannot be selected (try a neighbour). The agent's next move
differs for each, so collapsing them would cost it a round trip or a retry
loop.

* feat(studio): give an agent eyes with studio_frame

Renders the composition to a PNG at a given time and returns the URL. This is
what turns the tool set from a remote control into a loop: author a change,
capture the instant it affects, look, adjust. No agent can judge motion from
source, because "what does this look like at 2.4 seconds" is not a question a
file answers.

Reuses Studio's existing capture endpoint via `buildFrameCaptureUrl` rather
than inventing a second one.

Two things this does not fake:

It reports the time the playhead LANDED on, not the time requested. The player
clamps, so those differ at the ends, and attaching the wrong time to a frame is
how an agent draws a confident wrong conclusion about motion.

It waits before capturing, by default 150ms. The frame is rendered from the
file on disk, and the render cache is cleared by a file watcher with a 40ms
write-stability threshold, so a capture that beats the watcher renders the
PRE-edit composition. That exact staleness was a real bug here once. An agent
reading a stale frame as "my edit failed" would thrash, so the wait is on by
default, `settleMs` makes it tunable, and the tool description names the
failure rather than leaving it to be rediscovered.

It probes with HEAD before returning, so a URL that 404s comes back as a
failure with a hint instead of as a link the agent cannot render.

* feat(studio): add studio_inspect, so an agent reads before it writes

Everything about one element in one call: resolved styles, text fields, box,
data attributes, GSAP animations, and what the element will and will not
accept.

The point is to prevent a failed write rather than to satisfy curiosity.
`can.reasonIfDisabled` is passed through verbatim from Studio's own
capabilities, so an agent that reads first should never attempt an edit the
element would refuse.

Three things it refuses to get wrong:

Animations are reported ONLY for the current selection, because that is the
only element Studio parses them for. Attributing them to any other element
would be reporting the wrong element's motion, which is worse than reporting
none. When a handle names something else the field is empty and
`animationEditingBlocked` says why.

`animationEditingBlocked` also carries the two states where animation editing
is off entirely, multiple timelines and an unsupported timeline pattern. Both
live on the selection context. Learning them from a read costs one call;
learning them from a failed write costs a retry loop.

Inspecting a handle does NOT change what is selected. It is a read, and
stealing the human's selection would be a side effect they did not ask for.
There is a test asserting `applySelection` is never called.

Nothing selected and no handle given is a failure, not an empty result. An
empty result would assert "this element has nothing", which is a different and
false claim.

* feat(studio): let an agent edit text and styles, guarded

The first tools that change the composition. Both act on the current
selection and take no handle, which is forced rather than chosen: the
handlers read the ambient React selection, and `applyDomSelection` only
schedules a state update, so selecting and committing inside one call would
write to whatever was selected before. Select first, then edit.

Also plumbs the write-blocked state, which was the blocker for shipping any
write at all. `domEditSaveQueuePaused` and the external-file conflict both
lived on App and were unreachable from the tool surface, so `canWrite` was
optimistic and a comment said so. They now derive into a single
`writeBlockedReason` on the shell context: one field, one owner, conflict
taking precedence because resolving it is what unblocks the queue.

That guard matters more than it looks. Both states are BANNERS in Studio with
no lock behind them, so nothing else was stopping a programmatic write from
landing on top of a conflict the user had been asked to adjudicate.

Three things the tools refuse to fake:

They check the outcome, not the absence of a throw. Studio has several paths
where a failed commit resolves anyway, so awaiting the handler proves nothing.
The tagged outcome added earlier is what proves the write landed.

A partial style result is reported as partial. `handleDomStyleCommit` is one
property per call, so N properties are N commits; the result carries `applied`
and `rejected` maps rather than a single boolean that would have to pick a
side.

Style commits run sequentially, never concurrently. Two commits racing through
Studio's client-side read-modify-write can record undo entries that both claim
the same starting content. There is a test that measures concurrency rather
than trusting the loop.

Every decline reason maps to a hint naming what to do instead, so a refusal
routes the agent rather than just stopping it.

* feat(studio): add studio_inspect, so an agent reads before it writes (#3517)

Everything about one element in one call: resolved styles, text fields, box,
data attributes, GSAP animations, and what the element will and will not
accept.

The point is to prevent a failed write rather than to satisfy curiosity.
`can.reasonIfDisabled` is passed through verbatim from Studio's own
capabilities, so an agent that reads first should never attempt an edit the
element would refuse.

Three things it refuses to get wrong:

Animations are reported ONLY for the current selection, because that is the
only element Studio parses them for. Attributing them to any other element
would be reporting the wrong element's motion, which is worse than reporting
none. When a handle names something else the field is empty and
`animationEditingBlocked` says why.

`animationEditingBlocked` also carries the two states where animation editing
is off entirely, multiple timelines and an unsupported timeline pattern. Both
live on the selection context. Learning them from a read costs one call;
learning them from a failed write costs a retry loop.

Inspecting a handle does NOT change what is selected. It is a read, and
stealing the human's selection would be a side effect they did not ask for.
There is a test asserting `applySelection` is never called.

Nothing selected and no handle given is a failure, not an empty result. An
empty result would assert "this element has nothing", which is a different and
false claim.

* feat(studio): move, resize and rotate, verified by reading back (#3519)

`studio_transform` does what a drag does, and then checks. The box in the
result is READ BACK after the write, never echoed from the request, and
`applied` lists what actually took effect.

That is not belt-and-braces. The plan for this unit said to re-derive the
geometry handlers' behaviour rather than trust any description of them, and
doing that turned up three different behaviours behind one interface.

The handlers on `DomEditActionsValue` are the GSAP-AWARE wrappers, aliased in
`useDomEditSession.ts:534-538`, not the CSS ones in `useDomGeometryCommits.ts`
that an earlier note in this workstream described.

`handleGsapAwarePathOffsetCommit` and `handleGsapAwareRotationCommit` are
`if (gsapCommitMutation) { ...intercept... }` with no else branch. Their own
comments say the absence is deliberate: position and rotation are written as
GSAP code and there is no CSS fallback to write to. So they can return having
done nothing.

`handleGsapAwareBoxSizeCommit` is not like the other two. It runs through
`runGestureTransaction` with separate scale and width/height routes, so resize
works more generally.

Reading back is what turns that middle case from a silent lie into a reported
one. A move that did nothing comes back in `unchanged` with a reason.

Three smaller decisions:

Operations re-read between each other, so a move is judged against the box
AFTER a resize in the same call. Comparing against the original would credit
the resize's change to the move.

Rotation is reported as dispatched, not verified. `rotate` is an individual
transform property and does not appear in the computed transform, so there is
no honest box-derived signal, and claiming one would be worse than saying so.

x pairs with y and width pairs with height. Accepting one alone would mean
inventing the other from the current value, which moves the element somewhere
the caller did not ask for. The pairing rule and its minimum live in one
`parsePair` helper rather than as four separate branches.

---------

Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-31 07:47:11 +00:00
3337cc8990 feat(studio): give an agent eyes with studio_frame (#3516)
* feat(studio): let an agent drive Studio's selection and playhead

Adds `studio_select` and `studio_seek`, so an agent and the human are looking
at the same element and the same instant. Selecting reveals the inspector,
exactly as a click does, which is what makes the agent's move visible.

Selection is shared state, not a per-call argument, and that is forced rather
than chosen. Most of Studio's edit handlers read the ambient React selection,
and `applyDomSelection` only schedules a state update, so selecting and
committing inside ONE call would write to whatever was selected before. Two
tool calls are separated by a render, so the contract is select first, then
act. That is also how a human works: click, then type.

`studio_seek` uses `requestSeek`, not `setCurrentTime`. The latter only moves
the timeline's displayed number and leaves the composition where it was.

Two things the tools refuse to fake:

Seek does not clamp. `seek()` already clamps against the adapter's duration,
which can differ from the store's, and clamping again would give that
invariant two owners that can disagree. The tool reports where the playhead
actually landed instead, read back afterwards.

`requestSeek` is fire-and-forget, so it cannot report that no adapter was
mounted to receive it. The tool compares the playhead before and after and
fails rather than claiming a seek that never happened.

Select separates three failures that a single message would have merged: the
preview is not mounted yet (wait), no element matches the handle (re-read),
and the element cannot be selected (try a neighbour). The agent's next move
differs for each, so collapsing them would cost it a round trip or a retry
loop.

* feat(studio): give an agent eyes with studio_frame

Renders the composition to a PNG at a given time and returns the URL. This is
what turns the tool set from a remote control into a loop: author a change,
capture the instant it affects, look, adjust. No agent can judge motion from
source, because "what does this look like at 2.4 seconds" is not a question a
file answers.

Reuses Studio's existing capture endpoint via `buildFrameCaptureUrl` rather
than inventing a second one.

Two things this does not fake:

It reports the time the playhead LANDED on, not the time requested. The player
clamps, so those differ at the ends, and attaching the wrong time to a frame is
how an agent draws a confident wrong conclusion about motion.

It waits before capturing, by default 150ms. The frame is rendered from the
file on disk, and the render cache is cleared by a file watcher with a 40ms
write-stability threshold, so a capture that beats the watcher renders the
PRE-edit composition. That exact staleness was a real bug here once. An agent
reading a stale frame as "my edit failed" would thrash, so the wait is on by
default, `settleMs` makes it tunable, and the tool description names the
failure rather than leaving it to be rediscovered.

It probes with HEAD before returning, so a URL that 404s comes back as a
failure with a hint instead of as a link the agent cannot render.

* feat(studio): add studio_inspect, so an agent reads before it writes (#3517)

Everything about one element in one call: resolved styles, text fields, box,
data attributes, GSAP animations, and what the element will and will not
accept.

The point is to prevent a failed write rather than to satisfy curiosity.
`can.reasonIfDisabled` is passed through verbatim from Studio's own
capabilities, so an agent that reads first should never attempt an edit the
element would refuse.

Three things it refuses to get wrong:

Animations are reported ONLY for the current selection, because that is the
only element Studio parses them for. Attributing them to any other element
would be reporting the wrong element's motion, which is worse than reporting
none. When a handle names something else the field is empty and
`animationEditingBlocked` says why.

`animationEditingBlocked` also carries the two states where animation editing
is off entirely, multiple timelines and an unsupported timeline pattern. Both
live on the selection context. Learning them from a read costs one call;
learning them from a failed write costs a retry loop.

Inspecting a handle does NOT change what is selected. It is a read, and
stealing the human's selection would be a side effect they did not ask for.
There is a test asserting `applySelection` is never called.

Nothing selected and no handle given is a failure, not an empty result. An
empty result would assert "this element has nothing", which is a different and
false claim.

---------

Co-authored-by: miga-heygen <miguel.sierra_miga@heygen.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-30 10:00:23 -07:00
Miguel Ángel 0e558d5916 feat(studio): let an agent drive Studio's selection and playhead (#3515)
Adds `studio_select` and `studio_seek`, so an agent and the human are looking
at the same element and the same instant. Selecting reveals the inspector,
exactly as a click does, which is what makes the agent's move visible.

Selection is shared state, not a per-call argument, and that is forced rather
than chosen. Most of Studio's edit handlers read the ambient React selection,
and `applyDomSelection` only schedules a state update, so selecting and
committing inside ONE call would write to whatever was selected before. Two
tool calls are separated by a render, so the contract is select first, then
act. That is also how a human works: click, then type.

`studio_seek` uses `requestSeek`, not `setCurrentTime`. The latter only moves
the timeline's displayed number and leaves the composition where it was.

Two things the tools refuse to fake:

Seek does not clamp. `seek()` already clamps against the adapter's duration,
which can differ from the store's, and clamping again would give that
invariant two owners that can disagree. The tool reports where the playhead
actually landed instead, read back afterwards.

`requestSeek` is fire-and-forget, so it cannot report that no adapter was
mounted to receive it. The tool compares the playhead before and after and
fails rather than claiming a seek that never happened.

Select separates three failures that a single message would have merged: the
preview is not mounted yet (wait), no element matches the handle (re-read),
and the element cannot be selected (try a neighbour). The agent's next move
differs for each, so collapsing them would cost it a round trip or a retry
loop.
2026-08-30 05:11:55 +00:00
Miguel Ángel 724796e2f0 chore: release v0.8.20 (#3555) 2026-08-30 00:31:08 -04:00
Miguel Ángel 28be8dddfa fix(studio): correct save failure telemetry (#3499) 2026-08-30 00:24:46 -04:00
Miguel Ángel 0fd70b1d21 chore: release v0.8.19 (#3551) 2026-08-29 13:58:33 -04:00
Miguel Ángel b71f45981c fix(studio): export the composition the user has selected (#3550)
The header's Export button started renders with no options at all, so the
request carried no `composition` and the server fell back to index.html.
Selecting a sub-composition in the Comps panel showed its canvas and timeline
but exported the root file instead.

Studio starts renders from three controls, and the render target was owned by
each of them separately: the Renders panel resolved it, the header omitted it,
the sidebar's per-composition button named one explicitly. Give it one owner
in `startRender`, which all three route through, defaulting to the active
composition and leaving an explicit argument to win.

Fixes #3549
2026-08-29 13:54:27 -04:00
Miguel Ángel bddc9e9bba Merge pull request #3548 from heygen-com/release/v0.8.18
chore: release v0.8.18
2026-08-29 11:57:23 -04:00
Miguel Ángel 5cc2f1bef5 chore: release v0.8.18 2026-08-29 15:38:26 +00:00
Miguel Ángel 7bd0782737 Merge pull request #3547 from rajanpanth/fix/studio-activate-comp-any-path
fix(studio): activate a composition at any path, not just compositions/
2026-08-29 11:32:53 -04:00
Miguel Ángel 3dc1856232 fix(core): resolve long nested media in local time (#3535)
* fix(core): resolve long nested media in local time

* test(core): cover nested media snapshot sweep
2026-08-29 02:48:47 +00:00
Miguel Ángel d99eeef0b2 fix(cli): honor authored playback rate in snapshots (#3536) 2026-08-29 02:48:33 +00:00
Miguel Ángel e4dabf830c fix(cli): prevent keyframe shots overwriting sources (#3534) 2026-08-29 02:43:37 +00:00
Miguel Ángel b28747df0f fix(cli): respect timeline-free static compositions (#3533) 2026-08-29 02:43:33 +00:00
Miguel Ángel af1cb1c10d fix(studio): update WebMCP test for polyfill fallback (#3532)
## Summary

- The `useStudioAgentTools` test "registers nothing when the browser has no WebMCP" asserted `document.modelContext` was absent after mount
- Since #3514 added the `@mcp-b/global` polyfill, the hook installs `document.modelContext` even without native browser support — that's the polyfill's purpose
- Updated the test to verify that mounting doesn't throw (the real contract), without asserting polyfill absence

This fixes the `CI / Test` failure on `main`.

## Other main CI failures (not ours)

- **Docs / Validate docs** — `npm error notarget No matching version found for mint@4.2.837` (docs tool dependency)
- **Windows / Tests on windows-latest** — `ECONNREFUSED ::1:3000` (Windows network test infra)
- **regression** — cancelled, not failing

## Test plan

- [x] All 9 `useStudioAgentTools` tests pass with `NODE_ENV=development`
- [x] Pre-commit hooks: lint, format, fallow, typecheck all green

— Miga

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-28 01:40:41 -04:00
Miguel Ángel 10a90688e7 Merge pull request #3531 from heygen-com/release/v0.8.17
chore: release v0.8.17
2026-08-27 20:40:10 -04:00
Miguel Ángel f6de05efec chore: release v0.8.17 2026-08-28 00:35:58 +00:00
Miguel Ángel 097d901d70 feat(studio): fall back to a WebMCP polyfill where the browser has none (#3514)
* feat(studio): fall back to a WebMCP polyfill where the browser has none

WebMCP is an Origin Trial. Chrome 149 and Edge 150 have it behind a flag,
ChatGPT Desktop ships it, and everything else does not. Without a fallback the
tools registered in the previous change are invisible on stable Chrome, which
is exactly where a bridge extension would connect from.

Adds `@mcp-b/global` (MIT) as a DYNAMIC import, so a browser with native
support never fetches it. Verified in the build output rather than asserted:
the bundle keeps a bare `import("@mcp-b/global")` instead of inlining it.

Chosen over the smaller `@mcp-b/webmcp-polyfill` because that one only defines
`document.modelContext`. `@mcp-b/global` also stands up the in-page MCP server
a bridge extension attaches to, and serving that case is the only reason the
fallback exists at all.

The load is guarded by a module-level promise so two mounts racing share one
load, and an import failure is caught and logged rather than thrown: a missing
agent surface must never stop Studio booting. The registration path re-checks
the abort signal after the await, so unmounting mid-import registers nothing.

Two things the type checker forced, both worth keeping:

Installing the package brings its own global `Document.modelContext`
declaration, which collided with the local one. Studio now reads the property
through a type guard instead of augmenting `Document`, so there is only one
declaration of that global and it is the package's.

Studio keeps its own narrow tool types rather than importing the package's.
Theirs overload `registerTool` to infer argument types from a literal
`inputSchema`, which helps when registering one tool inline and fights a
uniform registration loop. The comment in `types.ts` says so, and names the
drift risk that choice accepts.

The polyfill test asserts promise identity rather than counting imports. The
ESM registry dedupes the import either way, so a call count would pass whether
or not the guard existed.

* fix(studio): observe and retry WebMCP fallback
2026-08-27 00:07:45 -04:00
Miguel Ángel 94da403d6d feat(studio): expose Studio's live state to an agentic browser (WebMCP) (#3511)
* feat(studio): expose Studio's live state to an agentic browser

Registers a `studio_look` tool on `document.modelContext`, so an agent in a
browser that supports it can read what Studio knows: the open project and
composition, the playhead, the human's current selection with its
capabilities, and the timeline's elements with a handle for each.

The API is `document.modelContext`, not `navigator.modelContext`. The latter
is a polyfill compatibility shim rather than a spec member, so feature
detecting it is wrong even where a published sample appears to work.

Three decisions worth knowing:

Registration happens ONCE per mount, with the dependencies held in a ref that
every render refreshes. Depending on the handlers instead re-runs on nearly
every interaction, because the DomEdit actions object changes identity with
the selection and the element list. Each re-run aborts the registration signal
and unregisters everything, and the spec warns that a quick unregister-then-
reregister can apply an old call's arguments against the new schema. The test
for this is the important one in the unit; breaking the empty dependency array
fails it and nothing else.

Tools resolve with a tagged result, they never reject. That is forced by the
spec: a rejected `execute` has its reason discarded and the caller sees a bare
UnknownError, so rejecting would guarantee the agent cannot learn why an edit
failed.

Elements are addressed by a minted handle, not by `TimelineElement.id`. That
id is a synthesised identity, so `getElementById` misses most elements; the
handle carries `data-hf-id`, else the DOM id, else a selector plus occurrence.

Mounted from `EditorShell` rather than `App`, because the DomEdit contexts are
only readable below `DomEditProvider` and `App.tsx` is three lines under the
600-line cap.

The undo signal is reported as the shell actually exposes it, `canUndo` and a
label, rather than as a revision counter. The depth lives in component-local
state and is not reachable without plumbing it through the shell context, so
the field says what it is instead of implying precision it does not have.

Writes are not in this change. `canWrite` is optimistic and the comment says
so; the write tools need a real guard against the paused-save and external-
conflict states, which are not on any context this component can reach yet.

* fix(studio): bound WebMCP look filters

* fix(studio): remove premature WebMCP write state

* docs(studio): name WebMCP singleton assumption

* fix(studio): surface WebMCP registration failures
2026-08-26 23:59:49 -04:00
Miguel Ángel 21bcd5745c fix(studio): let a failed DOM edit report that it failed (#3510)
* fix(studio): let a failed text or style commit report itself

`runDomEditCommit` catches a persist failure, reverts, fires `onError` and
then resolves. That contract is deliberate and its docstring says so: the
human path learns the write failed from the toast `onError` puts on screen,
so a rejection would be redundant. It also means a caller awaiting
`handleDomTextCommit` or `handleDomStyleCommit` cannot tell a landed write
from a reverted one, because both resolve with `undefined`.

The runner already offers `onSettled` as the way out. Text and style were
the two commits that never got it wired.

Add `runReportedDomEditCommit`, which owns `onSettled` (forwarding to a
caller-supplied one rather than dropping it) and returns whether the write
landed. Both handlers now return a tagged outcome, so the three preconditions
that previously returned early and silently are each distinguishable:
no selection, a manual-geometry property the style path refuses, and a
selection that cannot edit styles. Same for text: no selection versus not
text-editable.

Human-facing behaviour is unchanged and the tests assert that: the toast
still fires and the optimistic DOM change is still reverted.

The callback props that carry these handlers ignore the result, so their
declared type widens from `Promise<void>` to `Promise<unknown>`. That type is
hand-copied in fourteen places; consolidating it is worth its own change.

`useDomEditTextCommits.ts` is now 593 lines against the 600-line cap. The
next change to it needs a split.

* fix(studio): stop a paused save queue reporting a position edit as saved

Two more commits that could not tell a caller they had failed.

`useDomEditPositionPatchCommit` swallowed `DomEditSaveQueueOpenError` and
resolved. The intent was right, a paused save queue already puts a banner on
screen and one toast per blocked edit is noise, but swallowing it also
skipped the caller's revert: `useDomGeometryCommits` only restores the
optimistic offset, size or rotation from its `.catch`. So once the breaker
opened, a drag left the element where the user dropped it while nothing
reached the file, and the next reload snapped it back.

It now rejects without toasting. The banner still does the telling; the
caller gets to revert.

`handleDomEditElementsDelete` caught everything and only toasted, so an
unpatchable target and a completed delete were indistinguishable to a caller.
It now returns an outcome, with `no-project` and `no-selection` separated from
a failed write rather than all three sharing an early `return`.

Adds the first test for `useDomEditPositionPatchCommit`, covering the paused
queue, an ordinary failure, and success.

* fix(studio): honor DOM edit failure outcomes

* fix(studio): classify stale delete previews

* fix(studio): enforce DOM edit outcome types
2026-08-26 23:42:57 -04:00
Miguel Ángel 7170dc63ae Merge pull request #3522 from heygen-com/release/v0.8.16
chore: release v0.8.16
2026-08-26 21:36:26 -04:00
Miguel Ángel 720ff5ac9c chore: release v0.8.16 2026-08-27 01:32:37 +00:00
Miguel Ángel c9f43ebcfb fix(engine): preserve source frame identity above 99,999 (#3503)
* fix(engine): preserve extracted frame identity

* fix(producer): order legacy distributed frames numerically
2026-08-26 12:30:49 -04:00
Miguel Ángel 0c9d234bd8 Merge pull request #3481 from heygen-com/fix/web-audio-cross-origin-silence-v2
fix(core): prevent cross-origin Web Audio capture from silencing audio
2026-08-25 23:41:58 -04:00
Miguel Ángel cd6a25dab5 Merge pull request #3497 from heygen-com/release/v0.8.15
chore: release v0.8.15
2026-08-25 23:24:30 -04:00
Miguel Ángel 740f7ead89 chore: release v0.8.15 2026-08-26 03:23:41 +00:00
Miguel Ángel daf4840db3 Merge pull request #3492 from heygen-com/fix/font-subset-css-case-transform
fix(fonts): unify preview and render font resolution
2026-08-25 23:18:56 -04:00
Miguel Ángel 7c40efbc62 fix(fonts): harden localizer release diagnostics 2026-08-26 03:02:24 +00:00
Miguel Ángel d6de083411 feat(cli): stamp font compiler version in localized HTML 2026-08-26 01:11:38 +00:00
Miguel Ángel ec68d40cc6 fix(cli): keep font localizer process ownership explicit 2026-08-26 01:11:38 +00:00
Miguel Ángel 38f8f9250a feat(cli): expose deterministic font localization 2026-08-26 01:11:38 +00:00
Miguel Ángel 744146eb6e fix(fonts): cover rendered case variants in subsets 2026-08-26 01:11:38 +00:00
Miguel Ángel f7fc0017a6 fix(core): evict cached MediaElementSource on src mutation, soften enforcement-point claims 2026-08-26 00:01:33 +00:00
Miguel Ángel 32ef37cfc3 fix(core): correct web-audio-route subpath export resolution 2026-08-25 23:00:34 +00:00
Miguel Ángel acc6898255 fix(core): address review — gate early diagnostic, fix empty crossOrigin, document gaps 2026-08-25 05:25:24 +00:00
cce17da5a9 fix(core): prevent cross-origin Web Audio capture from silencing audio
Classify each <audio> element before Web Audio capture: same-origin,
CORS-opted-in, or a non-http(s) scheme stays on the primary
createMediaElementSource() path; cross-origin media without a
crossorigin opt-in withholds that call (the Web Audio spec makes such a
node output silence without throwing) and falls back to fetch +
decodeAudioData, preserving the FX graph whenever the server allows
CORS. Recheck the route at the transport's irreversible capture
boundary, and account for currentSrc, src, and <source> candidates the
same way the HTML resource-selection algorithm does.

Emit a stable preview diagnostic (`runtime_web_audio_bypass`) at media
discovery time, not only from playback scheduling, so `hyperframes
check` — which seeks but never plays — can surface it as a
`web_audio_bypass` finding. Diagnostics are suppressed during export
rendering, where the producer mixes audio offline and already applies
the FX chain. The existing non-unit-rate fail-closed rule stays scoped
to fx-chain/automation so this fix does not newly mute grouped or
above-unity tracks.

Takes over #3459 with the data-native-audio escape hatch removed per
review feedback: the automatic cross-origin detection already covers
the cases that mattered, so the extra per-element opt-in attribute,
its route-classifier branch, and its diagnostic path are dropped in
favor of a single automatic behavior.

Fixes #3458

Original-Author: desenmeng
Co-Authored-By: desenmeng <desenmeng@users.noreply.github.com>
Co-Authored-By: Miga <noreply@anthropic.com>
2026-08-25 04:39:51 +00:00
Miguel Ángel a562946e11 fix(scripts): stop the large-file guard firing on text (#3475)
`docs/changelog.mdx` reached 512 KB and started failing the 500 KB pre-commit
check, so `chore: release v0.8.14` could only be committed by passing
`HF_MAX_NONLFS_KB`. Every release from here would need the same override, and
the file grows a few KB each time.

The check is for binaries. Its own error message says "large binaries are being
committed to git instead of LFS", and its header explains why: an ONNX model,
HDR-regression MP4s, demo clips, each of which lives in history forever and is
paid for by every clone. That cost is specific to binaries. Git delta-compresses
text, so release notes that grow a few KB per commit add a few KB to the pack,
while a binary of the same size re-enters the pack whole on every edit.

Text is now exempt regardless of size, detected with `grep -I` (a file with NUL
bytes is binary), the same heuristic git uses for "Binary files differ". The
binary rule, the `registry/` exemption, the LFS check and the size threshold are
all unchanged.

The alternative was an allowlist entry for the one file, which would leave the
next legitimately growing text file to hit the same wall and get the same
one-off exemption.

`scripts/check-large-files.sh` had no test. It has one now, wired into
`test:scripts` so it actually runs: an over-limit binary fails, an over-limit
text file passes, an under-limit binary passes, and multiple offenders are all
named. Verified the text case fails with the exemption removed, so the test
pins the behaviour rather than describing it.
2026-08-24 20:07:03 -04:00
Miguel Ángel 81069fe47f chore: release v0.8.14 (#3474) 2026-08-24 20:03:19 -04:00
Miguel Ángel 045b3a4fd7 feat(cli): report which catalog items a render actually used (#3470)
`registry_item_added` fires when a catalog block is installed and
`render_complete` fires when a video is produced, but nothing joined them, so
"did this video use the catalog?" had no answer.

`hyperframes add` now records each installed item in `hyperframes.json`
(installed files are plain composition HTML with no provenance marker, so this
manifest is the only record that a file came from the registry), and
`render_complete` reports both the items the project installed and the blocks
the rendered composition actually reaches. An item installed and then never
mounted was tried and dropped, which no add-time event can express.

The scan answering "which sub-compositions does this file mount" now has one
owner, `collectSubCompositionSrcs` in `@hyperframes/parsers`, shared with
lint's `lintMissingOrEmptySubComposition`. It holds two invariants that were
previously restated per call site and got re-derived wrongly: it is a text scan
rather than a DOM query, because `<template>` content is inert and every
sub-composition except the render entry is wrapped in one; and references
resolve root-relative at every nesting level, matching `parseSubCompositions`.
It walks tag by tag rather than running open-ended spans across the whole file,
so a malformed composition cannot stall the render plan.

Also: `registryItems` is declared in the config schema, which closes with
`additionalProperties: false`, with an ajv-backed test pinning every key the CLI
writes; counts are never truncated by the name cap, and the reported used blocks
stay a subset of the reported installed ones, with `registry_items_truncated`
marking a windowed list; and an unreadable manifest reports itself rather than
posing as a project that never used the catalog.
2026-08-24 19:56:06 -04:00
Miguel Ángel b2fc18b2df fix(skills,lint): correct composition-contract claims the code contradicts (#3468)
The runtime absorbed a series of authoring mistakes over time and `runtime/init.ts`
says so in its own comments, but the skills kept teaching the old rules. Four of
them actively cost an agent a failing run: add `crossorigin` (lint rejects it
unconditionally), never build a timeline inside `async` (lint calls that the
documented contract), never `gsap.set` later-scene clips (two fixHints instruct
exactly that), and 12 copyable media snippets with no `id`, which render silent.

Corrected in every place each claim appeared, including `hyperframes-animation`,
three workflow scripts, the scaffolded project instructions, the CLI `docs`
command, and the public docs site: `data-track-index` is a Studio display lane
the render never reads, `class="clip"` is a layout convention rather than a
visibility requirement, timed elements may nest, the visibility window is
half-open, sub-composition host dimensions are backfilled, and the root-fill rule
applies only to the layered-composite path.

Behaviour changes, each backed by a render rather than by reading code:

- `timeline_registry_missing_init` deleted. The runtime creates the registry
  before any inline script; a composition without the guard line renders and
  animates correctly.
- `video_nested_in_timed_element` kept, message corrected. A rendered repro shows
  the nested-with-local-start case really does break, so the rule guards a real
  defect, but nothing is "FROZEN": the extractor ignores the wrapper's offset
  while visibility uses it, so the clip shows wrong frames and then vanishes.
- `mediaRenderIds` now stamps media whose source is a `<source>` child, closing a
  duplicate-id gap the old `[src]`-only selector left open.
- Stale messages fixed on `subcomposition_root_styled_by_class` and
  `deprecated_data_layer`.

`coreSkillContent.test.ts` pinned the literal sentence that made root
`data-start` look required, so it is narrowed to structure plus the regression it
genuinely catches.

Not covered, and flagged in the PR: the media global-vs-local start heuristic in
`runtime/init.ts` is the root cause behind the nested-video defect. Removing it
changes the meaning of existing compositions and needs its own deprecation.
2026-08-24 18:04:39 -04:00
Miguel Ángel e5a5e6b151 fix(cli): keep overlap waivers local to marked text (#3464)
* fix(cli): scope overlap waiver to marked text

* fix(skills): guard changelog caption rail

* fix(skills): densify changelog caption checks

* test(skills): satisfy strict seek typing
2026-08-24 14:22:03 -04:00
Miguel Ángel 32d58a73e3 chore: release v0.8.11 (#3440) 2026-08-23 14:49:13 -04:00
Miguel Ángel 65b2299db2 fix(engine): preserve static dedup across caption runs (#3438)
* fix(engine): preserve authored clip boundaries after normalization

* perf(engine): bound static verification work across caption runs

* fix(core): preserve explicit nonpositive timeline windows
2026-08-23 13:18:18 -04:00
Miguel Ángel 59a69a145b chore: release v0.8.10 (#3426) 2026-08-22 11:16:32 -04:00
Miguel Ángel 7a024cf68e fix(studio): name the cause when a render request fails (#3424)
The render POST's catch took no binding, so the exception was discarded and
every transport failure produced one sentence: "Could not reach render server.
Use `hyperframes render` from the CLI instead."

A dead server, a DNS failure, an aborted request and a server that died
mid-render are all indistinguishable under that string — and it is not only a
UI message, it is what travels into the feedback report. Three separate field
reports carried it verbatim, one of them describing a render that fails every
single time. A guaranteed reproduction that tells us nothing is worse than an
intermittent one that does.

Bind the error and append it. The CLI guidance stays, since it is still the
right next step for the user; it just no longer stands alone.

Regression test asserts both halves: the cause appears, and the guidance
survives. It fails on the unfixed code with `expected 'Could not reach render
server. Use `h…' to contain 'Failed to fetch'`.
2026-08-22 11:04:16 -04:00
Miguel Ángel 92a6076807 test(engine): budget the ffmpeg audio-level tests, and make a stall say why (#3410)
`places a delayed track on its authored start` timed out on the windows runner
and failed an unrelated PR, the second time this week an ffmpeg audio test has
done that.

The previous fix raised the budget in `audioMixer.grouping.test.ts`, which was
the file the symptom named. It was the wrong scope: that file was the only audio
suite with explicit timeouts at all. `audioMixer.level.test.ts` had none, so its
two real-ffmpeg tests ran on vitest's 5s default. The failing one takes ~137ms
locally, so the runner is not 36x slower — but 5s was never a budget anyone
chose for a full mix.

Applied to the suite rather than to each test, so there is one home for it, and
scoped to the ffmpeg-gated describe: the sibling parsing suites are pure and
should keep failing fast at 5s.

Headroom alone would only have delayed an undiagnosable failure. The ffmpeg
process timeout is 5 minutes by default, far above any test budget, so a stalled
mix could only ever surface as a bare "Test timed out" with no stderr and no
failing stage. Tests now cap it at 20s and assert through a helper that reports
`failures` instead of collapsing to `expected false to be true`.

Both claims verified rather than asserted: a deliberately 6s test now passes
where the 5s default would have killed it, and forcing the process timeout to
1ms reports `stage: "prepare", reason: "ffmpeg_timeout"` instead of a timeout.

Reviewing with whitespace ignored is much smaller: adding the third argument to
`describe` reindents the suite body, so 131/101 is really 34/4.
2026-08-21 21:31:58 -04:00
Miguel Ángel ea95b7d44e fix(cli): stop a caught post-render throw reporting a valid render as failed (#3409)
A render that produced and validated its artifact still exited 1. Reported
again from the field on 0.8.7: the MP4 was on disk and an independent ffprobe
and full decode both passed, and the CLI exited 1 immediately after logging
`artifact validated`.

`render-success-state.ts` exists for exactly this and documents three earlier
cases, so the sentinel was already there. Its gap is which paths read it: the
uncaughtException and unhandledRejection handlers both consult
`isRenderSucceeded()`, but a post-render throw that the command wrapper CATCHES
never reaches either. It becomes an ordinary non-zero CommandResult, and
`finalizeCli` wrote that straight to `process.exitCode`.

The result was a run that disagreed with itself: `commandSucceededForTelemetry()`
already lets a validated render override a failure, so telemetry recorded
success while the shell saw exit 1.

Sanitize once in `finalizeCli`, where every command result funnels through,
rather than wrapping the individual steps. Which step threw does not matter;
that the artifact is committed does. The throw is still printed, so it stays
visible for diagnosis without being fatal.

Reproduced first as a failing test (`expected 1 to be +0`) on macOS, so this is
not Windows-specific — the field reports are one instance of it. A second test
pins the other side: a command that throws with no validated render still exits
non-zero, so the sanitizer cannot swallow a genuine failure.
2026-08-21 21:01:43 -04:00
Miguel Ángel 9c73e64a07 test(engine): give the audio grouping mixes room, and make a stall say why (#3408)
`a group FX chain fully cutting its members leaves an ungrouped track
untouched` timed out on the windows runner, failing an unrelated PR. The whole
file runs in ~4s locally and that test in ~1.1s, so 30s was not generous — but
the runner is roughly 10x slower and this test drives more ffmpeg than any of
its siblings, two full mixes plus a group FX chain. 30s was the tightest budget
in the package; 60s is what the rest of the ffmpeg-driven engine tests use.

Headroom alone would only have moved the same undiagnosable failure later,
because nothing here could report why. The production ffmpeg process timeout is
5 minutes, far above any test budget, so a stalled mix could only ever surface
as "Test timed out in 30000ms" with no stderr and no failing stage. Tests now
cap it at 20s, and the mix wrapper throws the recorded failures instead of
returning `success: false` into an `expect(...).toBe(true)` that reports
`expected false to be true` and discards the reason.

Verified by forcing the process timeout to 1ms: the failure goes from a 30s
wall-clock timeout to a 150ms error naming the stage, reason and element
(`stage: "prepare", reason: "ffmpeg_timeout", elementId: "a"`).

This does not explain the Windows stall itself, which I could not reproduce on
macOS. It makes the next occurrence report what it was doing.
2026-08-21 19:46:06 -04:00
Miguel Ángel 41edbfb2ce fix(lint): surface unloadable media variable defaults, stop reading data-var-src ids as paths (#3406)
A data-var-src value the runtime refuses to load is dropped at bind time and
the element's authored fallback src renders instead, so the video ships the
wrong media and the render still exits 0. lint said nothing, because the
scheme allowlist only existed inside the runtime.

Move that predicate into @hyperframes/parsers, where both the runtime and the
linter can reach it, and error at lint time on any declared default it
rejects. The value provably cannot load, so there is no false positive.

While reproducing that, lint turned out to report an unrelated missing file:
`\bsrc\s*=` also matches the tail of `data-var-src="bg"` (hyphen to `s` is a
word boundary), and `[^>]*` is greedy, so the variable id beat the real src
earlier in the same tag. Every binding was reported as a missing asset named
after the variable, and `<audio data-var-src>` was told the render would be
silent. All three copies of that regex now share one helper that requires
whitespace before the attribute.
2026-08-21 19:20:19 -04:00
Miguel Ángel 5842dd8df4 fix(studio): invalidate the preview signature off the watcher that sees project writes (#3364)
* fix(studio): invalidate the preview signature off the watcher that sees project writes

The preview ETag is a hash of the project's files, memoised per project
directory. That cache was cleared from Vite's own watcher, which
`server.watch.ignored` deliberately excludes `data/projects/**` from, so
nothing ever cleared it: the ETag stayed frozen for the life of the dev
server, the preview answered every revalidation with 304, and the browser
went on serving the composition as it was when it first loaded.

The visible cost is thumbnails. Their disk cache key already content-hashes
the composition, so an edit correctly asks for a fresh capture, but the
capture is taken against the stale page, and a clip's filmstrip keeps
showing frames of a layout that no longer exists until the dev server is
restarted.

Studio already runs its own chokidar watcher over exactly these
directories, because Vite's would answer a composition edit with a full
page reload. That watcher now owns the invalidation, and the cache asks it
to follow any project directory it has not seen. All five event types
count: an added or deleted asset changes the signature as surely as an
edited one.

The cache moves behind `createProjectSignatureCache` so the invalidation
rule is a unit under test rather than a subscription buried in the adapter.

* fix(studio): filter signature invalidation, and stop the CLI server missing motion saves

Review follow-up on the unfiltered invalidation.

The watcher fired on everything under a project dir, but the signature walk
skips 14 directories and `.thumbnails` is one of them. That directory is
where the thumbnail route keeps its disk cache, and every capture also reads
the preview, so populating a timeline row discarded the memo on roughly every
request of the one workload it exists for.

The filter is a single exported predicate beside the exclusion set it reads,
and it is applied inside `invalidate` rather than at the watcher, so no caller
can subscribe and forget it. It is deliberately not `WATCHER_EXCLUDED_DIRS`:
that set is character-identical but drops all of `.hyperframes/`, and the
signature reads two manifest files back out of there.

Which is the same bug, still live, in the CLI server: its watcher filters
through `shouldWatchProjectFile`, so `.hyperframes/studio-motion.json` never
reached the listener that clears the cached signature. Studio writes that file
at runtime, so saving motion state left the preview ETag stale until restart.
The watcher now admits signature-relevant paths and the reload listener
re-applies its own filter, so what triggers a browser reload is unchanged.

Also from review: drop the `createViteAdapter` signature-cache default, which
produced exactly the memo-nothing-clears bug this PR fixes, and correct the
docstring — the content hash is already gated behind a stat fingerprint, so
what the memo saves is the walk.
2026-08-21 19:13:37 -04:00
Miguel Ángel 09a5ef7092 fix(lint): stop duplicate_composition_id firing on repeated sub-composition mounts (#3404)
sub-compositions.md documents mounting one sub-composition several times
with different data-variable-values to get per-instance variations. That
necessarily repeats data-composition-id, so the rule reported our own
documented pattern as an error and blocked check with no correct way to
satisfy it.

The rule bucketed every element by id with no awareness of
data-composition-src, so it could not tell a composition root from a
mount. The runtime already distinguishes them: repeated mounts are
rewritten to id__hf1, id__hf2 so they coexist, and render, validate,
inspect and snapshot all handle the pattern.

Skip mounts, the same way the rule already skips tags inside an inert
template. The collision it exists for is unaffected: its own fixHint
names a <meta> tag carrying the root's id, and that tag has no
data-composition-src.

Closes #3403
2026-08-21 18:59:21 -04:00
Miguel Ángel e1191edba6 fix(producer): anchor local-font embedding to its url() occurrence (#3405)
The embed step rewrote the compiled document with
result.replaceAll(localPath, dataUri) — a bare substring replace with no
surrounding syntax. That also rewrites the path anywhere else it appears,
including inside a LONGER url whose tail happens to match, producing a
corrupted value like url("file:///abs/data:font/woff2;base64,...").

Any two paths where one is a suffix of the other collide the same way;
img/logo.ttf and assets/img/logo.ttf are enough. Every sibling rewrite in
this file already anchors on url(...), so this one was the outlier.

Also add file: to LOCAL_FONTFACE_URL_RE's exclusion list. Without it an
absolute file:// src was classified as a project-relative path and
resolved to <projectDir>/file:/abs/..., and the failed read was swallowed
by an empty catch. That catch now logs, since a silently skipped font
means the composition renders in a fallback typeface with nothing saying
why.

Closes #3369
2026-08-21 18:58:21 -04:00
Miguel Ángel 41af866bcb chore: release v0.8.7 (#3402) 2026-08-21 15:21:20 -04:00
Miguel Ángel 9bb4b4ce60 fix(lint): break two fix-loops and drop two rules the runtime owns (#3400)
An eval of time-to-finished-video against Remotion found the whole gap sits
after authoring, in the lint/check -> fix -> recheck loop. Lint execution is
3-5% of wall; the cost is the model turn each finding triggers. So the
expensive rule is the one an agent cannot satisfy, and the next most expensive
is the one that fires on correct code.

Two rules could not be satisfied at all.

`gsap_fullscreen_overlay_starts_visible` on a from() reveal was a closed cycle.
It errored on `tl.from("#flash", { opacity: 0 })`, which is not a defect:
from() seats its start values immediately, so on a paused timeline the overlay
already measures opacity 0 at t=0. Both of its fixHints (authored CSS
`opacity: 0`, or an immediate `gsap.set`) turn that working composition into a
real defect, which `gsap_from_opacity_noop` correctly errors on -- and that
rule's fixHint says to remove the very thing we just asked for. Applying either
hint bounced between the two errors forever.

The root cause was not the reporting condition but `laterHidden`, which counted
the reveal itself: a from-tween records its START values, so
`from({opacity: 0})` read as its own later hide. Excluding the reveal, and
excluding from-tweens (which end visible), is what actually fixes it. The
later-hidden shape still reports and still converges.

`caption_text_overflow_risk` told authors to add `overflow: hidden`, which is
exactly what `caption_overflow_clips_scaled_words` errors on. Following the
warning produced an error. The hint now says to keep overflow visible.

Two rules asserted a failure that cannot happen.

`root_composition_missing_data_start` errored because "the runtime needs
data-start=0 on the root element to begin playback". The runtime sets it itself
-- init.ts:286-292, whose comment reads "Agents sometimes omit data-start on the
root composition element ... Default to 0 for the root." The rule demanded the
fix the runtime had already applied. 16 of the 643 shipped registry files.

`overlapping_clips_same_track` claimed overlapping clips "cause rendering
conflicts". Nothing reads the track index at render: timeline.ts:586 states
"Track index is display-only; render never reads it", and grepping
`trackIndex|track-index` across engine and producer source returns zero hits.
Two clips overlapping on one track is a crossfade.

Two false positives fixed rather than removed, because the invariants are real.

`timeline_id_mismatch` fired on the legal one-liner
`window.__timelines = { main: gsap.timeline({ paused: true }) }`. The body regex
was non-greedy, so it stopped at the brace of the inlined OPTIONS object, and
the entry scanner harvested `paused` as a composition id. The resulting fixHint
named a registration that does not exist, so it could never be applied; hoisting
to a variable was the only escape and nothing said so. It now walks brace depth
and reads top-level keys only.

`non_deterministic_code` fired on `new Date("2026-01-01T00:00:00Z")`, which is
deterministic, and on `Math.random()` inside a string literal that a code-display
composition renders but never executes. Date is now zero-arg only, and patterns
run against string-stripped source -- except the GSAP `"random(...)"` tween
value, where the string IS the executed value and must still be scanned. That
exception is what the first attempt got wrong; the existing tests caught it.

Measured over the 643 shipped registry files: 386 -> 370 errors, no new codes.
The corpus does not exercise the loop cases, so it understates the change -- the
point is the turns those loops cost, not the finding count.
2026-08-21 15:15:12 -04:00
Miguel Ángel d4765512df fix(catalog): render the Matrix Decode docs preview; remove Checkout Flow (#3396)
* fix(catalog): reveal Matrix Decode captions with autoAlpha so the docs preview renders

The demo and snippet flipped word spans with zero-duration display sets,
which the seek-driven docs player never applies after its style restore:
the composition played 8s of black. autoAlpha reveals with the scrambles
as same-length absolute overlays follow the keyframes contract (never
tween display) and survive seeks and loop wraps. Scramble text now
matches each word's length so it decodes in place instead of jumping.

* chore(registry): remove the Checkout Flow component

Owner-directed removal of the checkout-flow catalog item: source,
demo, generated docs page and payload, and its entries in the registry
manifest, catalog index, docs nav, and search vectors. The deletions
are allowlisted in check-no-main-deletions.
2026-08-21 15:14:11 -04:00
Miguel Ángel a1c1f519cb fix(core): bind native window methods in the scoped sub-composition proxy (#3378)
The scoped window proxy handed native methods back unbound, so `this` at
call time was the Proxy and Chrome rejected it with "Illegal invocation".
That broke window.addEventListener, setTimeout, matchMedia and
getComputedStyle inside every sub-composition, including the
window.addEventListener("hf-seek", ...) form the Three.js and TypeGPU
adapters document. The sibling document and gsap proxies in this file
already bound; this one was the outlier.

Bind only non-constructors: Function.prototype.bind drops static members,
so binding a class exposed on window would silently strip its statics.
Built-in methods have no .prototype, classes and constructors do.

Closes #3376
2026-08-21 15:11:53 -04:00
Miguel Ángel 8b67bb6db5 fix(cli,studio): surface project lint in Studio (#3393)
* fix(cli,studio): surface project lint in Studio

* fix(studio): preserve per-file lint coverage
2026-08-21 15:11:24 -04:00
Miguel Ángel 77566a198b test(engine): give the ffmpeg-bound grouping mixes their 30s timeout (#3398)
audioMixer.grouping.test.ts spawns real ffmpeg per assertion and ran on
vitest's 5s default; on slow Windows runners the FX-chain and envelope
cases land right at the line and fail runs that touch nothing in the
engine. The other ffmpeg-bound engine suites (videoFrameExtractor)
already carry a per-test 30_000 timeout; this brings the grouping suite
in line.
2026-08-21 15:01:44 -04:00
Miguel Ángel 9ec75a485f docs: drop --full-depth from skills install commands (#3399)
* Update skills.mdx

* docs: drop --full-depth from skills install commands
2026-08-21 14:45:01 -04:00
Miguel Ángel 63eb35041c fix(deps): bump puppeteer so the browser hides its console window on Windows (#3394)
Windows users see a console window per chrome-headless-shell worker during a
render. Those windows come from Puppeteer's own launcher, not from any spawn
in this repo, so the windowsHide work on our ffmpeg spawns could not reach
them.

@puppeteer/browsers added windowsHide: true to its spawn in 3.2.1. It is
absent in 3.1.0 and 3.2.0. puppeteer-core pins that dependency exactly, and
25.8.0 is the first release pinning 3.2.1 (25.5.0 -> 3.1.0, 25.6.0 and
25.7.0 -> 3.2.0), so 25.8.0 is the minimum that carries the fix rather than
a preference for the latest.

Verified after install that exactly one copy resolves, at 3.2.1, and that its
launcher carries the flag. A draft render still completes.

Refs #3379
2026-08-21 11:50:18 -04:00
Miguel Ángel 315a7b758c fix(engine): hide ffmpeg console windows on Windows (#3381)
ffmpeg and ffprobe are console-subsystem binaries and Node defaults
windowsHide to false, so every spawn opened a visible console window on
Windows. A render shells out dozens of times across parallel workers,
which flashed a burst of windows across the user's desktop.

Applied at every production spawn site rather than only the two named in
the report, since they all share the cause: runFfmpeg, both gpuEncoder
probes, ffprobe, streamingEncoder, audioExtractor and the distributed
version check. windowsHide is a no-op on macOS and Linux.

The dev-only parity and regression harnesses are left alone; they never
run on a user's desktop.

Closes #3379
2026-08-21 11:37:21 -04:00
Miguel Ángel a9ea07edde fix(cli): reject blank default composition entries (#3392)
* fix(cli): reject blank default composition entry

* fix(cli): complete blank entry safeguards
2026-08-21 11:00:43 -04:00
Miguel Ángel a897806798 refactor(lint): remove the head_leaked_text rule (#3385)
The rule fired on legitimate content and blocked check. A prose CSS
comment naming a tag, such as "the <body> rule below sets the base
font", was enough: HEAD_CONTENT_PATTERN ends the head at the first
<body> in raw source, so the token inside the comment truncated the
capture mid-<style>. The unclosed style tag then defeated the
strip-ignorable-blocks pass, and the stylesheet's own rules reached the
orphan-CSS matcher, which reported a valid nearby rule as the leak.

Removed rather than repaired. Across all 643 shipped registry files it
fires zero times, so it has never caught anything real here, while
producing at least one confirmed false positive that blocked a working
cloud render. It is an error, not a warning, so the cost of a false
positive is a blocked pipeline. Leaked text of this kind is also visible
in the very first preview frame, which is a faster and more reliable
signal than a regex over raw source.

Takes its seven helpers and eight now-dead patterns with it, plus four
orphaned test fixtures. VISIBLE_MARKUP_COMMENT_PATTERN is kept; it
belongs to visible_markup_comment.

Refs #3384
2026-08-21 00:21:39 -04:00
Miguel Ángel c056289d83 fix(audio): renumber timestamps between apad and atrim in mixed branches (#3380)
On FFmpeg 5.x through 8.0.x the samples `apad` appends carry timestamps
the following `atrim` misreads. A delayed branch then sounds at t=0
instead of its offset and, once four or more branches are mixed, the last
one disappears from the output entirely. No error is raised; the render
succeeds with wrong audio.

Reverting to `apad=whole_dur=` is not an option: #2769 moved off that
form because some builds reject the option outright ("Error applying
option 'whole_dur': Option not found"). Inserting `asetpts=N/SR/TB`
between the pad and the trim rebuilds the timestamps from the sample
count using only filters every build ships, so it fixes the misplacement
without giving up the portability that change bought.

Verified on FFmpeg 4.2.7, 7.0.2, an 8.x nightly and 8.1.1: the current
form is wrong on the middle two, the new form is correct on all four.

audioPadTrim.ts also pads with apad+atrim but has no adelay and is
correct on every version tested, so it is left alone.

Closes #3344
2026-08-20 21:17:50 -07:00
Miguel Ángel a340ed382a fix(studio): keep subcomposition timelines open during playback (#3382)
* fix(studio): keep subcomposition timelines open during playback

* fix(studio): address timeline playback review feedback
2026-08-20 22:42:22 -04:00
Miguel Ángel 7a8f8a0b45 chore: release v0.8.5 (#3375) 2026-08-20 19:03:09 -04:00
Miguel Ángel 2be5a03b80 fix(lint): stop erroring on the documented canonical clip block (#3374)
Linting the primitive-clip example from packages/core/docs/core.md produced
two errors against the docs' own linter:

    error  timed_element_missing_clip_class  el-3   <img data-start ...>
    error  self_closing_media_tag            el-4   <audio ... />

Both are now fixed, in opposite directions — one was the rule's fault, one was
the docs'.

`timed_element_missing_clip_class` claimed the element "will be visible for the
entire composition instead of only during its scheduled time range". That is
not what happens. `syncTimedElementVisibility` walks
`querySelectorAll("[data-start]")` and toggles `style.visibility` off the
ATTRIBUTE, with no reference to the class; the runtime's own init test pins it
with a bare `<div data-start data-duration>` carrying no `class="clip"`. Every
other consumer of the string "clip" — Studio's label derivation, the runtime's
timeline labels, core's selector helper — treats it as a name to skip, never as
a behaviour key. So the class is an authoring convention the tooling reads, not
the mechanism that hides the element.

The rule is therefore a warning rather than an error, and its message now says
what is actually true. `img` joins `audio` and `video` in skipTags: the three
media primitives sit on adjacent lines of the same documented clip block, all
three authored without `class="clip"`, and flagging only the `<img>` is what
made the documented pattern fail.

`self_closing_media_tag` was right and the docs were wrong: `/` is ignored on a
non-void element, so `<audio ... />` leaves the element open and everything
after it nests inside. Changed to `<audio ...></audio>`. The `<img ... />` on
the line above is a genuine void element and stays as it is.

The same false mechanism claim had been copied into the talking-head-recut
skill, in both the annotated example and the rules list, where agents read it
as fact. Corrected there too.

No effect on the 643 shipped registry files (this rule fires on none of them);
the change is to the documented pattern and to agent-authored compositions.
Regression test lints the canonical block verbatim and asserts it produces no
errors or warnings, so docs and linter cannot drift apart again silently.
2026-08-20 18:39:12 -04:00
Miguel Ángel f822200fb8 feat(telemetry): measure which lint rules fire, cost, and fail to converge (#3367)
* feat(telemetry): measure which lint rules fire, cost, and fail to converge

Lint rule changes are currently argued from anecdote. This adds the three
measurements needed to argue them from data.

`lint_report`, once per `hyperframes lint` or `hyperframes check`:
- `code_counts` / `codes` — which rules actually fire, and how often
- `rule_group_ms` — milliseconds per rule-source module (core, gsap, media, ...)
- `slowest_rule` / `slowest_rule_ms` — slowest single rule as `<group>#<index>`
- `rule_count` — how many rules this build ran

`lint_rule_streak`, once per finding that survives an edit to its file:
- `edits` — how many edits the finding survived
- `cleared` — whether it eventually went away

The streak event is the one that matters. A lint pass costs about 5ms, so
per-rule CPU is not what makes the authoring loop slow; a rule an agent cannot
satisfy is, because every failed attempt costs a full edit-and-relint cycle. A
single run cannot see that, so `lint_rule_streak` reconstructs it across runs:
high `edits` with `cleared: false` is a rule nobody can fix, and the
`cleared: true` distribution is the baseline to judge it against.

An iteration is counted only when the file's content digest CHANGED and the
finding is still there. Re-linting an untouched project is not an attempt,
which is what stops `check` (which lints on every invocation) from inflating
the numbers.

Rule identity is the source module plus an index within it. Naming all 86
rules would make the timings prettier but it is a refactor this measurement
does not need: the group locates the file, and the index locates the rule.

Version, agent runtime, CI flag, and invocation id are already attached to
every event by `trackEvent`, so lint pain can be split by CLI version and by
which agent produced it without adding anything here.

Privacy: only rule codes, counts, and timings are sent. Streak state lives in
~/.hyperframes/lint-streaks.json alongside config.json (so `rm -rf
~/.hyperframes` is still a full reset) and stores digests only — no file
paths, no project names, no composition source. Nothing is written and nothing
is emitted when telemetry is off. Entries expire after 14 days and are capped
at 500 files.

`EventProperties` gains string arrays and numeric maps. `codes` and
`code_counts` are inherently a set and a histogram; flattening them into
dynamic top-level keys would make them unqueryable. PostHog stores both
natively.

`trackLintRun` is the single call site shared by `lint` and `check`, and it
swallows every error — telemetry must never turn a green lint red.

* feat(telemetry): emit per-group rule counts so slowest_rule stays comparable

Review catch on #3367: `slowest_rule` is the one positional key in either
event. It is `<group>#<index>`, so adding or removing a rule renumbers every
later slot in that group and the same string means different rules in two
builds. #3366 does exactly that to 34 of 81 surviving slots, and `rule_count`
alone says only THAT the ruleset moved, not which groups.

`rule_group_counts` carries the per-group sizes alongside it, so a consumer
comparing two builds can tell which groups' indices still mean the same thing
without anyone having to remember which release dropped rules. `codes`,
`code_counts` and `rule_group_ms` are keyed by name and were never affected.

Also corrects the rule count in the RULE_GROUPS comment: 86, not ~60, as
LINT_RULE_COUNT in the same file computes.
2026-08-20 18:26:14 -04:00
Miguel Ángel 83ceaeb902 refactor(lint): drop seven rules that fire on correct compositions (#3366)
Each rule below either reports a hazard the compiler or runtime already
prevents, duplicates another rule's invariant with a weaker detector, or
cannot be cleared by its own fixHint. Measured over the 643 shipped
registry HTML files, this cuts lint output from 1740 findings to 507
(-70.9%) and removes 40 errors, with no new codes introduced.

- scene_layer_missing_visibility_kill: regex heuristic keyed on `#sceneN`
  ids. It only accepts the literal string `visibility: "hidden"`, so the
  canonical GSAP hard kill (`tl.set(el, { autoAlpha: 0 })`, which sets
  visibility hidden at runtime) never clears it — an unfixable error. It
  also matched the `0` inside `opacity: 0.5` and treated `.from({opacity:
  0})` entrances as exits. gsap_exit_missing_hard_kill owns this invariant
  using parsed tween timing and real clip boundaries, and accepts every
  hidden encoding.
- unscoped_gsap_selector: wrapScopedCompositionScript already rewrites
  string GSAP targets to the composition root for every sub-composition
  script (pinned by compositionScoping.test.ts "executes document and GSAP
  selectors inside the composition root"). The rule also never fired on a
  standalone sub-composition file or a <template> sub-comp.
- caption_transcript_parse_error: required the inline TRANSCRIPT array to
  be strict JSON so Studio could read it, but Studio's parseTranscriptArray
  already normalizes unquoted keys, single quotes, and trailing commas. It
  errored on ten shipped caption components whose transcripts Studio parses.
- composition_self_attribute_selector: warned that
  `[data-composition-id="x"] .y` leaks across instances, but
  scopeCssToComposition rewrites that selector to each instance's runtime
  scope. It was also the pattern the rest of the toolchain prescribes.
- timed_element_missing_visibility_hidden: strict subset of
  timed_element_missing_clip_class, which reports the same condition as an
  error, so it only ever added a second line saying the same thing.
- pointer_events_none: Studio selection ergonomics only, no render impact,
  on 124 of 211 shipped blocks.
- google_fonts_import: the producer resolves Google Fonts during
  compile/render, as the message itself said.

system_font_will_alias is narrowed to distributed/Lambda renders, where
system-font capture is off and the fallback is a real defect. Under a local
render the substitution is the renderer working as designed, so the info
tier is gone.

The three tests that used composition_self_attribute_selector as a probe
for "this style source was collected" now use scoped_css_missing_wrapper,
which still fires once per source.
2026-08-20 18:10:24 -04:00
Miguel Ángelandanikam13 d1482b0129 fix(skills): resolve the blueprint id from a qualified blueprint: field (#3337)
* fix(skills): resolve the blueprint id from a qualified `blueprint:` field

visual-design.md documents `blueprint:` as the id plus a `(Reproduce)` /
`(Adapt)` qualifier, and prints `dataviz-countup (Adapt)` as its worked example.
The packet builder used that raw field as a filename, so a qualified blueprint
looked for `<id> (Adapt).md`, found nothing, and inlined an empty string:
`selectedFile()` returns "" for a missing path. Every packet shipped without the
one document the frame was designed against, and the run still exited 0 with
nothing on stderr. `compose (Adapt)` missed the `compose` check the same way.

Parse the field into the id it names, once, so no caller resolves a raw field
value against the blueprints directory. A blueprint that resolves to no file is
now a named error rather than an empty section, matching how the builder already
treats a missing `src` and an oversize packet.

The existing tests only used bare ids, which is how the qualified form escaped;
they now cover both, and the missing-file case.

One owner: product-launch-video, faceless-explainer, pr-to-video and
general-video all delegate to frame-packets-core.mjs.

Co-Authored-By: anikam13 <22992075+anikam13@users.noreply.github.com>

* fix(skills): degrade, not fail, when the blueprints library is absent

Self-review catch on the previous commit. hyperframes-animation installs on
demand, so its blueprints/ directory can legitimately be missing — that is a
skill that isn't installed yet, not a frame naming a bad id. Throwing there
turned a silent degrade into a hard failure for a valid setup.

Distinguish the two: an absent blueprints/ warns and inlines nothing, exactly
as an absent rules/ already does in knownRuleIds; a present library that has no
file for this id still throws, because that is a typo or an unstripped
qualifier.

Co-Authored-By: anikam13 <22992075+anikam13@users.noreply.github.com>

* fix(skills): point two dead blueprint references at real shapes

CI surfaced these once an unresolvable blueprint stopped being silent. Both
named ids that have never existed in hyperframes-animation/blueprints/:

- faceless-explainer's frame template taught `messaging-multi-phase`, so an
  agent copying the template verbatim tagged a blueprint that resolves to
  nothing. dataviz-countup is what the same skill already uses in its own
  visual-design template and tests.
- pr-to-video's diff-excerpt guardrail fixture used `number-lockup`. The test is
  about diff excerpting and the id was incidental; the frame's own
  `counting-dynamic-scale` rule makes dataviz-countup the natural real shape.

A sweep of every `blueprint:` value across skills/ finds no others.

Co-Authored-By: anikam13 <22992075+anikam13@users.noreply.github.com>

---------

Co-authored-by: anikam13 <22992075+anikam13@users.noreply.github.com>
2026-08-20 16:37:29 -04:00
Miguel Ángelandanikam13 c66c9a4c76 fix(skills): stage SVGs that capture wrote into capture/assets/svgs/ (#3336)
`hyperframes capture` extracts inline SVGs into capture/assets/svgs/, and the
capture manifest advertises them to the agent as `assets/svgs/<name>.svg`, so a
frame names one in `asset_candidates` exactly the way it names a screenshot.
stageAssets searched only capture/{assets,assets/videos,screenshots}, so every
captured SVG resolved to nothing: logged as a non-fatal anomaly, and the frame
404'd the brand mark it had been told to use.

Add the directory to the search list, and cover it with a test that fails
without the fix.

lib/assets.mjs is byte-identical across product-launch-video,
faceless-explainer and pr-to-video, so the fix lands in all three. Folding it
into hyperframes-core/scripts/lib/, where frame-packets-core.mjs already lives,
is a separate change.

Co-authored-by: anikam13 <22992075+anikam13@users.noreply.github.com>
2026-08-20 16:22:20 -04:00
Miguel Ángel 9140c0eaa1 fix(core): keep authored gain above unity off el.volume in the sandbox bridge (#3349)
Authoring a clip above unity gain throws at runtime today.

## What breaks

`MAX_AUDIO_GAIN_DB = 12` makes `data-volume` legal up to ~3.98. The sandbox runtime's volume bridge assigns the product straight to the element:

```ts
el.volume = clipVolume * volume;   // init.ts, onSetVolume
```

`HTMLMediaElement.volume` is spec-pinned to [0,1] and **throws `IndexSizeError`** outside it — verified in Chrome, and the test DOM agrees:

```
el.volume = 2  →  IndexSizeError: Failed to set the 'volume' property...
```

The throw lands inside a `for` loop over every media element, so it takes the rest of the loop with it: every clip after the boosted one keeps whatever volume it already had, while `state.bridgeVolume` says the change was applied. A composition with one boosted clip stops responding to the volume control for every clip authored after it.

## The fix

Clamp what the element receives. That is not lossy, because the element was never where the boost lived — the transport gets the authored gain unclamped, and this PR pins that half too:

- `syncRuntimeMedia` hands `onElementVolume` both the element's clamped volume **and** the authored gain, so the transport can have the boost the element cannot hold.
- `setElementVolume` keeps that gain on the per-element node, clamped only to `MAX_AUDIO_GAIN`.

Those two paths already worked; they were untested, and they are the reason clamping the element is the right half to clamp.

## Tests

- `init.test.ts` — a boosted clip followed by a quieter one, both seeded with sentinels, then the real `set-volume` control message. Asserts the boosted element lands at 1 **and** that the clip after it still gets its own volume, which is what a throw mid-loop strands.
- `media.test.ts` — the transport receives the authored gain while the element stays legal.
- `webAudioTransport.test.ts` — the per-element gain node keeps a boost above unity.

All three mutation-checked: removing the clamp reds the first, and clamping the gain at either transport seam reds the others.

## Provenance

This is the last unlanded piece of #3280. That PR was rebased onto current `main` and collapsed from +3050 to +944, of which everything except these lines is either already merged (#3308, #3309, #3333, #3339) or duplicated by the open #3306 and #3310. Cutting it out separately because the throw is live on `main` now and shouldn't wait behind a PR that is otherwise redundant.
2026-08-20 12:42:31 -04:00
Miguel Ángelandanikam13 f0e637375f fix(studio): capture the storyboard frame hero at full resolution (#3338)
The thumbnail route bounds every preview capture to 240x135. That bound came
from the timeline, where thumbnails are small and numerous and their decoded
bytes are budgeted. The storyboard reuses the same route for its frame detail
hero, which is up to 900px wide, so the poster arrived at 240x135 and upscaled
past 7x on a retina display. Headlines survived it; body copy, table labels and
captions did not.

That is the surface where it costs the most. references/review-loop.md sends the
user here to confirm layout and real copy, and tells them to run no CLI in that
pass: "the poster is the only picture this pass needs".

Give the caller a way to ask for the composition's own dimensions, which the
route already supports as `output=source`, and fold the choice into a single
`surface` prop. Whether a poster is a tile or the hero decides both the crop and
the capture density, so one prop owns both rather than two that can disagree.

The contact sheet keeps the bounded capture: many tiles, and it is a contact
sheet. The timeline is untouched.

Reported with a reproduction and a correct read of the consequences in #3271.

Co-authored-by: anikam13 <22992075+anikam13@users.noreply.github.com>
2026-08-19 17:33:03 -07:00
Miguel Ángel 42b94fd5db chore: release v0.8.4 (#3359) 2026-08-19 19:28:13 -04:00
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 d464f60b96 fix(cli): zip the publish archive to the same bytes every time (#3358)
adm-zip stamps every entry with `new Date()` as it is constructed, and a ZIP
timestamp resolves to two seconds — so archiving identical content twice gave
different bytes whenever the two runs landed either side of a boundary. The
archive's digest was a function of the clock rather than of its contents, which
is backwards for something `cloud render` uploads and addresses by content.

It surfaced as a CI flake: publishProject.test.ts asserts two archives built
back to back are byte-identical, and both sides are the same expression, so the
only way it can fail is non-determinism. The window is narrow, which is why it
survived since July and why re-running always cleared it.

Entry times are now fixed. Built from local components deliberately:
`fromDate2DOS` reads getFullYear/getMonth/getHours, so a fixed instant would
still encode differently per timezone — verified identical bytes under UTC,
America/Los_Angeles and Asia/Kolkata.

The new test moves the clock across a boundary, which is what reproduces it;
back-to-back builds land in the same bucket almost always, which is exactly how
it hid.
2026-08-19 18:25:04 -04:00
Miguel Ángel 228eabd43f fix(studio): make the volume fader tell the truth about the gain it writes (#3305)
* fix(studio): make the volume fader tell the truth about the gain it writes

The fader travels in dB, so its stops are irrational values; serializing them
through the generic two-decimal numeric formatter collapsed the bottom quarter
of its travel onto "0" — a hard mute — and made the knob jump on release
everywhere below unity. Both panels now use the exact serializer, which
round-trips every integer stop back to itself.

Raise the volume automation lane to the same ceiling the fader reaches.
Clamping the lane at unity meant automating a boosted clip silently discarded
the boost, and the panel disables the fader while a lane owns the level, so
there was no way back. This rescales the lane's vertical axis: unity now sits
a quarter of the way up rather than at the top.

Add audio_volume_tween_overrides_gain. Tween values on `volume` are absolute —
they replace the authored gain rather than scaling it — so a clip carrying both
plays at whatever the tween names, and the fader gives no sign of it. The rule
reuses the tween detector the sibling lane/tween rule already has.

* fix(lint): treat a missing data-volume as unity, not as silence

readAttr returns null when the attribute is absent, and Number(null) is 0 —
finite, and not 1 — so a clip carrying NO data-volume cleared both filters and
was reported as authored at silence. Both halves of that were false: absent
means unity everywhere else in the runtime.

It fired on exactly the case the rule exists to bless. The docs this PR edits
say data-volume is the baseline for elements no tween touches, so a tweened
clip is expected not to carry one — the common audio fade. A warning does not
fail check, but an agent reading the fixHint would have written a gain to
correct a level that was never wrong.
2026-08-19 18:08:23 -04:00
Miguel Ángel b3c43e2480 feat(cli): add normalize-audio to match one clip's loudness to another (#3306)
* feat(cli): add normalize-audio to match one clip's loudness to another

Measures two authored `<audio>` clips with FFmpeg's integrated EBU R128
loudness and writes the target's matching `data-volume`, leaving the
reference untouched.

The measurement is bounded to the window the composition actually plays.
`data-end` bounds a clip's timeline window just as `data-duration` does, and
`-ss`/`-t` belong before `-i`: after it they bound the OUTPUT, and with
`-f null` there is none, so ebur128 keeps integrating past the clip. On a
fixture whose played window is -61.8 LUFS inside a file that measures -27.9
whole, either mistake reports a loudness the composition never plays and
"corrects" an already-matched clip by tens of dB.

Two EBU R128 passes run between reading the composition and writing it, each
bounded only by a two-minute timeout, and the skill docs tell agents to keep
Studio open meanwhile — so the attribute patch is re-applied to a fresh read
and written through a temp file and a rename.

Under `--json` the failures are documents too: an agent doing
`JSON.parse(stdout)` on a bare error line throws. A pair needing more than the
+12 dB ceiling has a source-file problem rather than a mixer one — mixer gain
raises the noise floor with the signal — so the refusal names the remedy.

* fix(cli): validate --tolerance before paying for the measurement

Each EBU R128 pass is bounded at 120s and normalize-audio runs two, so
parsing the argument afterwards made a typo'd --tolerance cost both of them
before failing on something that was wrong from the start.

Not pinned by a test: the ordering is internal to the command and neither it
nor the parser is exported, so covering it would mean restructuring for a spy
rather than asserting the behaviour.

* docs(cli): restore the blank line between the preview and normalize-audio sections

Lost when I resolved the rebase conflict against the background-preview docs
by hand instead of letting the formatter near it. oxfmt --check failed on the
one file, which fails Preflight — and because preview-parity needs Preflight it
skipped, and the preview-regression gate fails closed on a skip, so a missing
newline read as a preview defect.

The quieter half: the same needs chain meant the required Test context was
never created at that head. Not failing — absent, so there was no test signal
at all on the PR.
2026-08-19 17:36:10 -04:00
Miguel Ángel 9da422fd7f feat(cli): run a managed background preview in every launch mode (#3310)
`--background` was rejected outside the embedded server. It now re-execs the
CLI in foreground, which makes it mode-agnostic by construction: whichever
server the child resolves to serves the config endpoint the readiness probe
looks for. `--foreground` is its counterpart, for a non-interactive shell that
wants to stay attached, and a bare launch keeps the same promise — attached in
an interactive terminal, managed in an agent session.

That generalization exposed an existing hole. Local-studio mode runs Vite with
the studio package as its cwd and needs that package's own Vite config, which
the published tarball does not carry, but resolving the package was treated as
proof the mode was usable. An npm-installed studio therefore took a path that
can never come up — previously a clear error, now a ten-second silent timeout.
The predicate becomes "can this studio actually be served", so a published
install falls back to embedded mode, which works.

Over the 1k line budget at ~1.3k. The overage is one command file and its
tests carrying one invariant, and the seam that would split it further is
inside a single request-handling function — a split there would produce two
PRs neither of which starts a preview on its own.
2026-08-19 17:02:44 -04:00
Miguel Ángel 634df5a5af fix(producer): give inlined media a document-unique render id (#3342)
* fix(producer): give inlined media a document-unique render id

Element ids are unique per composition file, but the render document is
the inlined union of every file. The producer merged the per-file media
lists and deduplicated by id, so clips that shared an id collapsed into a
single entry, and every id-keyed stage (extract, inject, visibility,
bounds) resolved to whichever element came first in the document. The
surviving clip's frames landed on the wrong element and the visible scene
rendered without footage.

Two shapes hit this, and neither is author error:

  - Two scenes that each declare `<video id="clip">`. Legal per file, and
    unavoidable when a scene is duplicated into a copy with inner ids
    kept, or when one file is mounted twice.
  - Two scenes that each declare a bare `<video>`. The timing compiler
    numbers auto-ids per file, so both arrive as `hf-video-0` with no
    authored id involved at all.

Stamp a document-unique `data-hf-render-id` while inlining, and read the
media list off the inlined document instead of merging per-file lists.
The render id equals the element id whenever that id is already unique,
so documents without a collision keep identical pipeline keys.

Author `id` attributes are left alone: 158 of the 161 registry blocks
reference their own ids from `#id` CSS or getElementById, so renaming
would trade broken footage for broken styling. The engine resolves media
elements through the render id instead, falling back to getElementById
for documents the producer never compiled.

Collecting from the inlined document also retires the per-file media
extraction in parseSubCompositions along with its offset bookkeeping;
host offsets are recovered from the composition hosts the clip sits in.

* fix(core): resolve render-frame siblings by render id in the runtime

The injector creates each `__render_frame_<id>__` sibling from the media
element's render id, but four runtime readers still built that id from the
plain `el.id`. On a document where two compositions share a media id, all
of them resolved the first collider's frame.

colorGrading is the one that changes pixels: findRenderFrameImage returns
the image the grading pass samples, with no class check to catch the
mismatch, so the second video was graded from the first one's frame.
media, mediaProxy and video-texture-compat use it as a render-mode or
substitute-source signal, where both colliders happen to agree during
render, but none of them should rest on that.

Add renderFrameSibling as the single owner of "which frame belongs to
this element" and route all four through it. It reads the stamped render
id and falls back to the author id, so a collision-free document resolves
exactly as before and an uncompiled one (preview, snapshot, check) is
unchanged.

The engine's in-page bridge keeps its own copy of the rule because code
serialized into page.evaluate cannot import; it now names core as the
definition, and a test pins the sibling-id format both sides build so
they cannot drift apart silently.

* refactor(engine): build render-frame sibling ids from core's definition

The drift guard named both sides but pinned one. renderFrameSibling.test
asserts core's format, while the engine rebuilt the same id from a literal
template at six independent sites. Changing the format on either side left
the test green and every runtime reader silently unable to find its frame —
this PR's own failure mode, one level up.

Export the affixes and renderFrameIdForRenderId from core, and take the id
from there at all six. Four sites resolve it on the Node side, where the
engine can import; the two that iterate the DOM in-page receive the affixes
as evaluate arguments, which avoids depending on bridge install order.

Also switch two `__hfMediaId?.(el) ?? el.id` reads to `||`. The bridge
returns "" for an element with neither id, so `??` kept the empty string
and built `__render_frame___`, which no reader looks for. Inert today
because the compiler assigns positional ids to id-less timed media, but it
made the two sides disagree in the one case they could.
2026-08-19 00:24:07 -04:00
Miguel Ángel ec0b23f3ce fix(studio): make Delete remove the whole canvas selection (#3339)
* fix(studio): delete every clip in the selection, not just the first

Select all in the timeline, press Delete, and one clip disappeared while the
rest stayed — still drawn as selected.

The Delete hotkey built the selection set correctly and then called
`elements.find(...)`, which stops at the first match, and handed that single
element to a handler that deletes exactly one. The comment above it claimed the
handler "expands a clip that is part of the multi-selection into an atomic
delete of the whole selection (single undo)" — no such expansion existed
anywhere; `useTimelineEditing` never read `selectedElementIds`.

`handleTimelineElementsDelete` takes the whole selection and removes every
element before saving once, so the delete is a single history entry and a single
undo — what the comment already promised. The hotkey layer now takes only that
plural handler, since it never deletes one element in isolation; the singular
entry point stays for the context menu and clip chrome. The store drops every
deleted key and clears the marquee set, rather than leaving a selection drawn
around clips that no longer exist.

Elements whose `sourceFile` is not the composition being edited are dropped from
the pass rather than written to the wrong file.

Also removes the preview's double-click-to-reset-zoom. It was a document-level
capture listener, so any double-click anywhere over the viewport snapped the
zoom back to fit — including double-clicks meant for the content under it. The
explicit reset control beside the zoom HUD stays.

Reproduced by test: restoring `elements.find` reds the new marquee case.

* fix(studio): delete every canvas element in the selection, not just the primary

Selecting several elements on the canvas and pressing Delete removed one of
them and left the rest — still drawn as selected. The delete path only ever
took the primary selection; the marquee group it belongs to was ignored.

Expand the session-level delete through the group ref, the same way the other
group commits already do, and let the lifecycle op remove every member under a
single save so one Undo restores the whole selection.

* fix(studio): let the canvas selection own Delete instead of its timeline mirror

Marquee-selecting elements on the canvas and pressing Delete removed a
fraction of them. The hotkey routed to the timeline delete whenever the
timeline store held anything, and the timeline's copy of a canvas selection is
derived and lossy by construction — a member with no timeline row of its own is
dropped from it. Selecting 73 elements published 14 ids, so 14 went and 59
stayed, still drawn as selected.

The canvas selection is what the user drew the marquee around, so it owns
Delete whenever it holds something; the timeline path stays as the fallback for
rows with no canvas node to select. Both paths already remove through the same
endpoint, so this is one addressing scheme replacing two.

That makes the canvas delete the path a Delete press normally takes, so it
picks up the same mid-recording refusal the timeline delete has.

* fix(studio): let the marquee see the whole document, not the first 80 elements

Dragging a marquee over the entire canvas selected a fraction of what it
covered, so Delete left most of the page behind. The hit test sourced its
candidates from the layers-panel collector, which stops after 80 items — a
budget for how many rows that panel is willing to render, silently reused as if
it described the document. Everything past the 80th element in document order
was unselectable no matter where the user dragged. The off-canvas indicators
were reading the same truncated list.

The cap now belongs to the panel that wants it; the collector returns
everything. To pay for that, the marquee measures its candidates once when the
drag passes the threshold instead of re-reading layout for every element on
every pointer-move: unbounded plus per-move stalled the tab outright, and the
iframe DOM does not mutate mid-drag, so one pass stays true for the gesture.

On a captured page: one marquee, one Delete, 734 elements down to 81.

* fix(studio): report a no-op delete instead of claiming the elements went

A target the file no longer holds answers `changed: false`, which is normal
for a member nested inside another member already removed. Every target
answering that is not — it means the preview is describing a document the file
does not have, so each removal misses and the file is written back untouched.

The toast still said "Deleted 503 elements. Use Undo to restore them." That is
how a delete that did nothing at all looked from the outside: press Delete, the
page stays, nothing on screen explains it. Say the preview is out of date and
reload it instead.

* fix(studio): keep the canvas hotkeys alive across preview reloads

Pressing Delete with a canvas selection did nothing at all — no removal, no
toast, nothing on screen to explain it. A keypress goes to whichever document
has focus, and clicking the canvas puts focus inside the preview iframe, so the
app's hotkeys have to be forwarded there.

They were, but only from the iframe element's ref callback, which fires when
the element mounts. A preview reload keeps the same element, so the callback
never runs again, and keeps the same WindowProxy, so the forwarder's identity
check saw no change and skipped re-attaching — while the inner window holding
the listeners had been replaced. After the first reload the canvas had no app
hotkeys left. Undo and redo kept working because their forwarder re-attaches on
every load, which is why this read as "only Delete is broken".

Fold the app handler into that per-load forwarder so both attach in the same
place, on every load, and drop the mount-only one. Window only: the history
pair also listens on the document, and capture listeners on both would run the
app handler twice per press.

* perf(studio): stop re-probing every restored selection member on load

The hash carries the whole canvas selection, and restoring it asked the
server whether each member still exists in the source — one request per member,
awaited one after another. A marquee over a captured page puts hundreds of
members in the URL, so every later load of that URL spent hundreds of serial
round trips rebuilding the selection before the canvas answered anything,
keypresses included.

The marquee that produced those members already skips the probe. Restoring them
skips it too; only the primary, whose panel reads the flag, still pays for one.

* fix(studio): delete a canvas selection in one pass and say the key landed

Reproduced with a real, focus-routed keypress instead of a synthetic one: the
press does reach the handler and the delete does run to completion, but at
hundreds of members it takes seconds during which the canvas is unchanged and
nothing acknowledges the key. Silence for that long is indistinguishable from
Delete being broken, and pressing it again or reloading mid-flight lands in a
worse state.

Two things, one per cause. The removal now sends the whole selection in a
single request against a new remove-elements route, which reads the file once,
drops every member and writes once — it was a round trip AND a full rewrite of
the file per element. And a multi-element delete announces itself before the
work starts, so the press is visibly acknowledged instead of leaving the canvas
looking untouched until it finishes.

Measured on a captured page, 84 members: 933ms of serial round trips against
84 rewrites, down to 583ms and one.

* refactor(studio): narrow the SDK delete targets instead of asserting them

The batch SDK path guarded on every member having an hfId and then asserted
it away per member. Narrow once into a string list so the guard and the values
come from the same place, and drop a threaded content variable that never
changed — the SDK owns the document it edits, so every member is removed
against the same starting content.

Also mounts the new forwarding test through the existing harness rather than
repeating its setup.

* fix(studio): stop Delete acting on a canvas selection the user replaced

Two things the reordered Delete arbitration got wrong, both found in review.

A clip with no canvas node left the canvas selection pointing at whatever was
picked before it, and the canvas branch wins whenever that ref is non-null — so
selecting an audio clip and pressing Delete removed the previously selected
canvas element and left the clip, right after the toast said the clip was not
in the preview. The timeline fallback the comment described could not be
reached. Clearing that selection has to stay quiet: the clear is announced to
the timeline, so echoing it would deselect the clip that was just picked.

Expanding the primary to the marquee group also moved out of the delete handler
and up to the Delete key. Cut copies the primary alone, so expanding for every
caller put one element on the clipboard and removed every other member with it
— undo brought them back, paste restored one. The rule is a named function now,
so the two callers can differ without either guessing.

Also throttles the off-canvas indicator rebuild, which the cap had been hiding.
It walks every element in the preview and reads layout for each — measured at
6.5ms on an 825-element captured page against a 16.7ms frame — and what marks
it dirty is a MutationObserver on inline style, which is how animation writes.

* fix(studio): hold the canvas selection inside the timeline selection

The stale-canvas-selection defect survived at the second writer. The
store-driven sync bails when a member has not resolved yet and returned without
touching the canvas, so a pick with no canvas node at all left the previous
selection in place — and Delete acts on the canvas first, so it deleted that.
Reachable from the sidebar audio and asset reveals and from an asset drop, none
of which go through the handler already fixed.

Clearing on every bail would be wrong: the bail exists for a member whose node
is not ready, which a later run resolves, and clearing there would flicker.
Only a canvas anchor that resolves OUTSIDE the current selection goes, which is
the state that is dangerous rather than merely unfinished. Quietly, for the same
reason as the first writer: announcing would deselect the clip just picked.

The invariant is named now, since Delete depends on it: the canvas selection
never points outside the current timeline selection.

Also drops the x-hf-removed header, which nothing read and whose comment
promised a partial-vs-no-op distinction the response cannot make, and pins the
indicator throttle that was measured but uncovered.
2026-08-19 00:22:26 -04:00
Miguel Ángel 0e3c5f6bef feat(cli): give every preview lifecycle op one JSON document (#3309)
`--status`, `--stop`, `--list` and `--kill-all` emit a schema-versioned
envelope with an `ok` discriminant under `--json`, from one writer and one
failure-payload builder. Human output is unchanged; the JSON path is additive.

The value is in the failure paths. An agent that gets a bare error line on
stderr and an empty stdout cannot tell a crash from a "not running", so every
failure is a document too — including a missing project, which under `--json`
resolves through the throwing resolver rather than the human-shaped nudge.
2026-08-18 20:07:50 -04:00
Miguel Ángel e282ff15cc fix(audio): raise the authoring gain ceiling and carry it through the probes (#3333)
* fix(audio): raise the authoring gain ceiling and carry it through the probes

Builds on #3328, which made the preview graph apply author gain and user volume
exactly once each. That ownership is now correct but everything is still clamped
to 1.0, so a clip authored above unity cannot be heard or rendered.

`HTMLMediaElement.volume` is spec-clamped to [0,1], so both timeline probes lost
a clip's authored gain the moment it also carried a fade: the probe seeded the
element at the clamped value and every sample read back at or below 0 dB, and
the mixer prefers probed keyframes over the static volume. Both probes now
shadow the accessor for their own duration and forward the clamped value to the
native setter, so the authored gain survives while nothing outside the probe
ever sees an illegal volume.

Measured on one 6 s composition, first 4 s: unity -32.8 LUFS, boosted-with-fade
-32.8 before and -27.0 after — +5.8 dB, exactly the gain the clip was authored
at.

One ceiling, defined once in `audioGain.ts` and reachable from both sides: the
render mixer imports it, and the page-serialized probe takes it as a parameter
rather than re-literalling it. User volume stays spec-clamped — it is a fader,
not a gain.

Also holds the percent volume slider above unity in both property panels. That
control tops out at 100%, so one touch would cap a boosted clip and drop up to
12 dB that now genuinely renders; the dB fader that can represent these levels
replaces it in the next PR.

* fix(audio): carry a static above-unity gain onto the preview gain node

Review follow-up.

`setElementVolume` receives the clip's author gain and clamped it to [0,1],
so a static `data-volume` above unity was capped on the WebAudio preview path
while the render honoured it — the exact preview/render divergence this
ceiling exists to close. Automation lanes hid it: they schedule ramps onto the
param directly and never pass through here. The master volume beside it stays
spec-clamped, because a user fader is not a gain.

Verified by mutation: restoring the [0,1] clamp reds the new case.

Also scope the leveller's rationale to this rung — `VOLUME_RANGE` still stops
at unity until the dB fader lands, so "both now span the same range" was
premature — and say why the GSAP-tracking fallback is unity-capped: it reads
back through `el.volume`, which the spec pins to [0,1], so it cannot observe an
above-unity value however wide the clamp gets.

* fix(audio): restore the live test files this branch overwrote, and uncap preview

Review blocker: three files were wholesale copies from the abandoned #3304
branch laid over a two-day-newer base, so they silently reverted work that had
landed in between. CI could not see it — deleted tests do not fail.

- `audioMixer.test.ts` was byte-identical to #3304's head: 1186 lines against a
  base of 1353. Gone with it were the `data-playback-start` fallthrough cases
  from #3322 — merged 54 minutes before this branch's own merge base — and all
  retiming coverage (`playbackRate` 7 to 0, `atempo` 5 to 0), the strict
  literal-timing table, and the zero-window cases.
- `mediaVolumeEnvelope.test.ts` dropped the trailing-garbage duration case and
  the plateau-retention case.
- `packages/core/package.json` rolled the package version back 0.8.3 to 0.7.109.

All three are restored from `main` with only this PR's additions re-applied on
top, and the subpath export is regenerated by the repo's own script rather than
hand-edited.

Also closes the preview/render split the same review raised. Two clamps had to
go, not one: `setElementVolume` capped the author gain at the transport, and
the first-tick branch in `syncRuntimeMedia` trusted `el.volume` — which is
spec-bound to [0,1] and so cannot represent a boost, opening a boosted clip at
0 dB for one tick before the steady-state branch took over. Both pinned by
tests, both verified by mutation.
2026-08-18 20:07:42 -04:00
Miguel Ángel 74149e249a fix(cli): keep a live preview's ownership record and stop past a bad one (#3308)
* fix(cli): keep a live preview's ownership record and stop past a bad one

A missed liveness probe is not proof the preview is gone — a server blocked on
a Puppeteer capture answers nothing for a second or two — but any miss retired
the session record, and the record carries the only PID-reuse guard `--stop`
has. Reproduced by SIGSTOPping a managed preview and running `--status`: the
record was deleted and never came back, leaving every later stop to fall
through to an unauthenticated port scan with no ownership proof at all. Only a
wrapper process that is provably gone now retires a record.

That record gains a process-birth token so a recycled PID reads as a different
process, and it is written through a temp file and renamed — every reader
deletes it when it fails to parse, so a torn read would otherwise destroy a
live server's proof of ownership.

Two failure-propagation bugs in the stop path: `--kill-all` collected the
first unprovable record's exception and abandoned every server after it, so
they were left running AND unreported; and a replacement refused to launch
when the server it was replacing had already exited on its own, which is the
goal state rather than a failure. `--list` now shows managed sessions ahead of
whatever else answers the scan.

* fix(cli): keep a record whose identity lookup gave no answer, not a different one

Review blocker. The keep-alive path this PR adds could still retire a LIVE
record — through a different door than the one it closed.

`processIdentity` catches every failure into `null`, and on two of three
platforms that failure is a subprocess timeout on a live process: the win32
`Win32_Process` CIM query and the POSIX `ps -o lstart=` both run on a 2 s
budget, under exactly the load that made the HTTP probe miss in the first
place. A `null` compared unequal to the saved token, so the record was deleted
and `wrapperIdentity` — the only PID-reuse guard `--stop` has — was gone for
good. Only Linux, reading /proc directly, was reliable.

No answer is now distinguished from a different answer: the PID is checked with
`kill(pid, 0)` first, which asks the kernel without signalling and treats EPERM
as alive. A PID nothing can signal is gone and retires the record with no
subprocess at all; a signalable PID whose token cannot be read keeps it. Only a
token that comes back and differs retires it.

That ordering also answers the `--list` note: the identity subprocess no longer
runs for the stale records that made it slow, so the N x 2 s worst case is gone
along with the timeouts that fed the bug.

Verified by mutation: restoring the old "no answer means gone" behaviour reds
the new case. Also clean up the temp file when a rename fails, rather than
orphaning it in the session directory.

* test(cli): assert only what the birth-token lookup actually guarantees

`captures a stable birth token for the current process` made two assertions
that a lookup allowed to fail cannot support. `processIdentity` returns null
whenever the lookup cannot be completed — not only when the process is absent —
and on Windows and macOS it shells out to PowerShell or `ps` on a 2 s budget
that a cold CI runner routinely outruns.

Both failed on windows-latest, in sequence: first `.toMatch()` received null,
and once that was guarded, `expect(second).toBe(first)` compared a null from the
cold first spawn against a token from the warm second one.

Two lookups can disagree for exactly one reason — one of them failed — so
stability is only assertable across two successful ones. The token itself
cannot change between calls; it is a birth timestamp and the process did not
restart. `processIdentity(-1)` stays unconditional: the guard rejects it before
any subprocess runs.

The strict shape assertion moves to a Linux-only case, where /proc is read
directly with no subprocess and null is genuinely not allowed — keeping the
guarantee on the one platform that can honour it rather than dropping it
everywhere. Callers already depend on this contract: `wrapperProcessIsAlive`
treats null as "no answer" rather than "gone" precisely because it is reachable.
2026-08-18 19:50:02 -04:00
Miguel Ángel c1c70f44bd fix(cli): signal only processes the OS says own the port (#3307)
* fix(cli): signal only processes the OS says own the port

`/__hyperframes_config` is unauthenticated and the PID it reports is what
`--stop` and `--kill-all` send signals to, so any local process answering on
a scanned port could name an arbitrary PID and have the CLI kill it.
Reproduced with a twenty-line HTTP server on a scanned port self-reporting an
unrelated PID: before this, `--kill-all` killed that process; after it, the
process survives and only the real listener is stopped.

The listening PID now comes from the OS — `lsof`, and `netstat` on Windows,
where the lookup was previously unavailable and the self-reported value was
taken on trust. The response's own PID is used only where the OS lookup
fails, which is also the only case where it is unfalsifiable.

Orphan cleanup moves to the last step before a launch. It reaches outside the
process and kills other people's PIDs, so it must not run for an invocation
that turns out to be a validation error and never starts anything.

* fix(cli): fail closed when the OS cannot confirm who owns a port

Review follow-up.

The two halves of this change picked opposite directions for the same
condition. `isProcessDescendant` fails closed by design; `activeServerOnPort`
fell back to the self-reported PID whenever the OS lookup came back empty —
and that is not only "unsupported platform". `lsof` may be absent (the default
on many slim images), may time out, or may not see a socket owned by another
user. On such a machine every scanned port silently reverted to pre-change
behaviour, with nothing said.

Provenance is now part of the type rather than a convention: `ActiveServer`
carries `pidSource`, so a caller cannot mistake a self-report for the kernel's
answer. `--kill-all` requires `"os"` and skips the rest, naming the ports it
left alone and why. That is the deliberate trade — a blind sweep of a port
range has no evidence beyond an unauthenticated response, so an unconfirmed
PID must not be signalled. Managed previews are unaffected: they stop through
their session record, which proves ownership by process birth identity.

The fallback branch — the one with the security consequence — now has the
coverage it lacked, via an injected lookup matching the seam `testPortOnAllHosts`
and `isProcessDescendant` already use, including a live process that survives
because nothing confirmed it owns the socket.

Also state that `killProcessTree` honours `signal` on POSIX only: Windows
always passes `/F`, deliberately, since taskkill without it posts WM_CLOSE that
a console process may ignore. The caller-side comment claiming Windows cleanup
is a no-op described the code before this change and now says the opposite.
2026-08-18 17:41:46 -04:00
Miguel Ángel 3e4b08cdc1 chore: release v0.8.3 (#3327) 2026-08-18 11:11:46 -04:00
Miguel Ángel 995c9e346e fix(core): separate author and user audio gain (#3328) 2026-08-18 10:59:12 -04:00
Miguel Ángel afafca4b96 feat: make creator media edits render-safe (#3322)
* feat: make creator media edits render-safe

* fix: align media playback timing

* docs: add creator editing recipes

* docs: expand creator editing guidance

* fix: unify media source offsets

* fix: scale natural media duration

* fix: preserve natural media zero spans

* fix: align compiled natural media timing

* test: classify compiler media test as integration

* fix: drop inactive media windows

* fix: unify literal timing parsing

* fix: keep browser media parsing serializable

* fix: keep page timing readers strict

* fix: close remaining preview timing gaps

* fix(core): preserve Studio voice pitch at playback speed

* chore: keep creator contract source-neutral
2026-08-18 10:17:02 -04:00
Miguel Ángel 049f5618d7 chore: release v0.8.2 (#3324) 2026-08-18 01:57:54 -04:00
Miguel Ángel 406bf316a3 fix(catalog): make component previews answer their variables panel (#3323)
* fix(catalog): make component previews answer their variables panel

Every control on a component's catalog page did nothing. Asking
caption-camera-follow for a violet accent rendered gold, and so did green
and blue, on 166 of the 168 components that declare variables.

A component ships a snippet, which is what the page hands you to paste and
which carries the declaration plus the script that turns a chosen value
into a CSS custom property, and a demo.html which stages and animates it.
The preview is built from the demo, and the demo was authored as a copy of
the snippet rather than a reference to it. The copies drifted until almost
none of them carried the declaration or the reader, so the payload for that
page never contained the word violet at all.

Components come in two shapes, so the repair does too.

123 ship a snippet that registers its own paused timeline. That snippet is
a whole piece, so their preview is now built from it and carries markup,
variables and motion together.

45 are markup plus a commented recipe, where the demo owns the motion.
Those demos now carry the snippet's declaration, reader and var-driven CSS
in the registry itself, written by scripts/catalog/sync-demo-variables.ts.
Nothing is patched in at build time.

A test runs that tool in dry mode and fails when a demo has drifted again,
naming the command that repairs it. It also asserts it inspected more than
a hundred components, because a check that silently matches nothing is how
this rotted in the first place.

Measured by rendering every payload in a real player rather than by reading
markup: payloads declaring their variables go from 2 of 168 to 168 of 168,
previews that animate go from 166 to 167, and nothing that moved stopped
moving. ascii-render-pass and star-rating-fill render a still frame when
built from their snippet, so they keep the demo path as a recorded
exception and stay in the state they were already in.

* refactor(catalog): give the preview pipeline one lookup and one entrypoint guard

Follow-up on the same branch, no behaviour change: 42 tests still pass and
`sync-demo-variables --check` still reports all 168 components clean.

The payload generator and the demo sync had each grown their own copy of
"given a component directory, find the snippet and the demo". Both now call
`componentFiles`, which is the same duplication-by-copying that broke the
previews in the first place.

Both catalog generators also carried a byte-identical 12-line guard for
"only run main() when this file is the entrypoint". That clone was already
in the tree, but nothing had touched both files at once before, so it had
never surfaced. It is now `runAsCommand`, and the sync script's variant of
the same condition is `isEntrypoint`.

The rest is flattening: the layering guards read as a table of conditions
instead of a chain, the reporting splits by what it reports, and the entry
resolution comes out of `buildPayload` rather than being spliced into it.

Also runs the formatter over the demos this branch rewrote. Whitespace only,
and `notes-typing` is the only component demo that renders pre-formatted
text, which this does not touch.
2026-08-18 01:37:46 -04:00
Miguel Ángel 0d874adc68 fix(skills): count proxy-driver tweens in the animation map (#3301)
enumerateTweens dropped every tween whose targets() held no Element:

    if (!targets.length) return;

That silently deleted the proxy-driver idiom — tween a plain object, apply the
motion inside onUpdate — which is real, visible animation. The consequence was
not just a missing row: computeDensity counted zero active tweens across the
tween's span, so findDeadZones reported animating time as DEAD, telling agents
to add motion to a stretch that already had it.

A target-less tween is now kept when a driver reaches it, marked
driver:"onUpdate". The driver can be the tween's own onUpdate, or the
TIMELINE's — the WebGL/uniform idiom is gsap.timeline({ onUpdate: renderFrame })
over children that tween plain uniform objects and carry no onUpdate of their
own, so walk() threads a `driven` flag beside parentOffset.

The discriminator is what keeps this from trading one false reading for
another. A bare `tl.to({}, { duration: D })` spacer produces nothing, and every
preset caption skin ends with exactly such a full-span anchor; counting those
would mask genuine dead zones. So a tween's own onUpdate is proof of work by
itself (a repaint loop need not animate a property), while an inherited driver
additionally requires the tween to change something.

There is no element to select or measure for a driver tween:

  * selectorHint is null rather than a placeholder — it feeds
    document.querySelector, so it must be absent, not unmatchable;
  * bbox sampling is skipped; the report shows "(onUpdate driver)";
  * computeFlags guards its geometry flags on bboxes.length, since [].every()
    is vacuously true and would report an unmeasured tween as both degenerate
    and invisible;
  * describeTween says the motion is applied in JS and no geometry was measured;
  * the per-element analyses (buildElementLifecycles, detectStaggers) run over
    element-backed tweens only, so drivers cannot collapse into one pseudo-
    element or invent a stagger. Density, dead zones and the timeline still
    count them — those are per-span, which is what a driver has.

Verified end to end: a 4s composition with an element tween over 0-1s and a
proxy driver over 2-4s went from "1/1 tweens, dead zones: 1.5-4s" to "2/2
tweens" with no dead zone.
2026-08-17 22:00:17 -04:00
Miguel Ángel a41da86517 fix(skills): declare the caption brand font's style axis, not just its weight (#3300)
A font filename encodes more than a weight, but only the weight was ever read
out of it, so two faces of one family collapsed onto a single slot.

Google Fonts ships Newsreader as Newsreader-Italic-VariableFont_opsz,wght.ttf
and Newsreader-VariableFont_opsz,wght.ttf. The italic sorts first, both scored
400, so the italic claimed the family's only 400 slot, the upright was dropped
as a duplicate, and the surviving face was declared with no font-style at all.

@font-face is deliberately global — the composition CSS scoper exempts it,
since a face declaration cannot be scoped — so mounting captions re-pointed the
whole document's Newsreader at the italic file and every sibling composition
rendered in italics.

Faces now carry a font-style descriptor and dedupe on weight AND style.

The same fix had to land in build-frame.mjs, which renames captured fonts
BEFORE captions.mjs sees them. It dropped the style token while renaming, so an
italic file arrived as "Newsreader-Regular.ttf" and was then asserted upright —
leaving the global normal slot pointing at italic bytes even once brandFontFaces
understood styles. The staged filename is a contract: it must carry every axis
that distinguishes one face from another, and the dedup key must be the whole
face.

Second axis, same misparse: weight parsing matched WORDS only, so a Fontsource
capture (inter-latin-500-normal.woff2) scored a whole family 400 and shipped one
of its faces. A numeric axis in the filename now wins over the word heuristic,
anchored so it is not read out of the middle of a hash-named capture file —
a non-digit before it and no alphanumeric after, which keeps both the 4-digit
guard and separator-free names like Roboto900.ttf.

Tests pin both ends of the contract: a round-trip asserting the names
build-frame stages map back to the right weight+style through the real
brandFontFaces, plus a source check that no copy reverts to a weight-only name
or a hardcoded font-style:normal. captions.mjs also gains a parity pin across
the three workflows that ship it.

Not addressed: a VariableFont file is still declared at a single font-weight
rather than its range, so weights it could interpolate are still synthesized.
2026-08-17 21:51:52 -04:00
Miguel Ángel f8a1e2d315 fix(skills): pin UTF-8 in Python scripts instead of the platform code page (#3298)
Windows sizes Python's stdio and text-mode file IO to the ANSI code page
(cp1252), not UTF-8. Every skill Python script relied on that default:

  * analyze-beatgrid.py --print writes the glyphs cp1252 has no slot for
    (delta, arrow), so the brief died with UnicodeEncodeError on every Windows
    run — the reported crash;
  * its audiomap write_text() pairs ensure_ascii=False with the default file
    encoding, so a non-ASCII payload is unwritable there too;
  * lint_source.py read_text() raises UnicodeDecodeError before any rule runs
    when a Remotion source carries an em dash or a curly quote;
  * gen-stroke-path.py reads an SVG font whose glyph keys ARE literal
    characters, so a mis-decoded key stops matching the requested text.

Stdio is reconfigured to UTF-8 at import and every text-mode IO call names its
encoding. `errors` is carried across the reconfigure: it resets to "strict",
and CPython gives stderr "backslashreplace" on purpose so the diagnostic path
can never itself raise.

extract-audio-data.py also decoded ffmpeg's stderr strictly while reporting a
failure, which would bury the very error being reported on a Windows ffmpeg.

skills/python-encoding.test.mjs guards the class: it fails if any skill Python
script drops the stdio block or omits encoding= on a text-mode IO call. The
mode is read as a whole comma-delimited argument of mode characters only, so a
payload key like {"bpm": 120} cannot spell the check away.

Verified with a cp1252 stdio stream installed before module load, matching how
Windows starts the interpreter: pre-fix UnicodeEncodeError, post-fix both
glyphs present in the UTF-8 bytes. Not run on real Windows hardware.
2026-08-17 21:44:08 -04:00
Miguel Ángel ad84b00c90 chore: release v0.8.1 (#3319) 2026-08-17 21:16:00 -04:00
Miguel Ángel d7688f9943 fix(docs): load the player from latest, not a pinned minor (#3320)
* fix(docs): load the player from latest, not a pinned minor

The catalog pages pinned the player CDN URL to a minor line, and that pin
sat one line behind after the last release. Every page kept rendering, on
the older build, so nothing surfaced it: the only symptom was that a fix
published to npm never appeared on the docs.

The generator derived its pin from the player's package.json, which is
correct only if every page is regenerated on the release that moves it.
That is the step that did not happen, and it has to happen across 175
generated pages plus three hand-written files for the pin to be true.

A version carried in step across 178 places will be stale, and stale here
is silent. Ask for latest instead and there is nothing to carry.

This costs the ability to hold the docs back from a bad player release.
Paid deliberately: the pin did not buy that either, it only delayed the
good releases too.

A test asserts no pinned version comes back, and fails if it stops finding
the references at all, so it cannot pass by matching nothing.

* refactor(scripts): list tracked files instead of walking the tree

The pin guard hand-rolled a recursive directory walk with its own skip
list and size cap, which the audit flagged: helpers living in a test file
earn no coverage, so their complexity lands straight on the CRAP score.

git already knows which files to read, and ignores node_modules and build
output for us, so one call replaces the walker and both findings go away.
2026-08-17 21:11:12 -04:00