Commit Graph

842 Commits

Author SHA1 Message Date
Miguel Ángel 9549e038c4 perf(studio): a paused editor stops redrawing its overlays sixty times a second (#3846)
* perf(studio): run the editor overlays on one parkable frame loop

The composition rect, the selection and hover boxes, the off-canvas indicators
and the snap guides each owned an animation-frame loop that re-armed
unconditionally. On a paused, untouched editor that is four callbacks per frame
reading layout, and the snap-guide loop writes style on every one of them, so
the compositor kept committing 55 frames a second with nothing moving.

None of the four has a clock of its own; each only changes when something
observable happens. They now share one loop that runs at full rate for a moment
after a pointer, key, wheel, scroll, resize or visibility change, after a
preview message reporting a frame the overlays have not drawn, or after a
mutation inside the preview document, and otherwise polls four times a second
so a wake source nobody thought of costs a quarter second of staleness rather
than a frozen overlay.

The frame comparison on preview messages is load-bearing rather than an
optimisation: the paused preview posts an unchanged status message every 80 ms
so any listener can confirm its position, and treating that as news held the
overlays at 60 fps for the life of the tab.

* fix(studio): one overlay's throw must not stop the other four

The shared frame loop re-armed after running its subscribers, so a subscriber
that threw took down the loop and its idle-poll safety net permanently. The
four loops it replaced each re-armed first, which kept that failure to the one
overlay that caused it. It now re-arms before running anything and isolates
each subscriber, rethrowing out of band so the error still reaches the page's
error reporting.

The wake also sat outside the recognised-message check, so postMessage traffic
from an extension, devtools or any other embed on the page held the overlays
awake for 400ms at a time. Only the preview's own messages wake it now, and a
repeated paused status post still does not.

useMotionPathData had the same unconditional loop and is live whenever a
keyframed element is selected; it joins the shared one.

* fix(studio): wake the overlays only for the preview
2026-09-10 18:20:37 +00:00
renovate[bot] eae4892ae8 chore(deps): update dependency vitest to v4 [security] (#3789)
* chore(deps): update dependency vitest to v4 [security]

* fix(test): preserve test behavior on Vitest 4

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: James <james.russo@heygen.com>
2026-09-10 13:55:52 -04:00
James Russo c98d6fbac8 fix: keep caption transcript data inside generated scripts (#3847)
* fix: keep caption transcript data inside generated scripts

* test: locate caption data without an HTML filtering regex
2026-09-10 12:53:30 -04:00
Miguel Ángel 73dfe35e46 fix(preview): show the correct scene instead of every scene at once on some loads (#3848)
* fix(runtime): show the right scene when the preview DOM comes from another window

The Studio preview sometimes builds the composition body in the editor window
and adopts it into the preview frame's document. Adopted nodes keep the
prototypes of the realm that created them, so `node instanceof HTMLElement` is
false for every element in the composition even though the elements are
ordinary HTML sitting in that document.

Every guard in the runtime written as `if (!(node instanceof HTMLElement))`
then skipped the whole document, silently: nothing threw, nothing logged, and
readiness still reported success. The timed-element visibility pass wrote no
inline visibility at all, so on those loads the preview painted every scene on
top of every other and the editor drew off-canvas markers for elements the user
could not see. The auto-stamp pass stopped stamping too, which is why the
composition came up one timeline clip short.

Replace every realm-sensitive element check in the runtime with structural
predicates that ask what a node IS (node type, namespace, tag name) rather than
which window's constructor made it. Two local `doc.defaultView.HTMLElement`
workarounds are deleted with it: they fix only the case where the nodes belong
to the document's own realm, and adoption is exactly what breaks that.

* fix(studio): scrub the music track the timeline named, not the first audio

`resolveScrubAudioEl` tested `byId instanceof HTMLAudioElement` on a node from
the preview iframe's document. That node is an instance of the IFRAME's
`HTMLAudioElement`, never this module's, so the check was false on every load
and the `musicId` hint was dead. Scrub fell through to the first `<audio>` in
the document, which the comment right above it warns can be the voiceover, so
dragging the playhead could preview the wrong track. Ask what the node is
instead.

Also close the gaps an independent review found in the runtime fix:

- the cross-realm predicate test had no `<audio>` and no `<img>`, so reverting
  `isAudioElement` or `isImageElement` to `instanceof` left it green. It no
  longer does, and the audio case also pins `isMediaElement`, which composes
  from it and gates the media sync path.
- nothing stopped the runtime regressing. `lint-runtime-preview-guards.ts`
  gains a second check kind: patterns that must be ABSENT under a directory,
  seeded with DOM-typed `instanceof` under `src/runtime`, pointing at
  domRealm.ts. Comment lines and tests are exempt, both on purpose.
- domRealm.ts stated the adoption mechanism as settled fact. The mixed
  prototypes are measured; how the nodes get into the frame is not identified,
  and the docstring now says which is which. Its ownership claim is scoped to
  the runtime, since packages/studio still hand-rolls its own checks.
2026-09-10 08:06:43 -07:00
Miguel Ángel 0408fbcd99 perf(studio): scrubbing a large composition no longer stalls the editor (#3842)
* perf(studio): ask each ancestor once per off-canvas rebuild, not once per element

The dashed off-canvas markers are rebuilt whenever anything in the preview
changes, which during playback or a scrub is several times a second. Most of
what a rebuild asked about an element was really a question about its
ANCESTORS: does each node up to the root render, what does each contribute to
the composed transform, which node is the source-file boundary. Two siblings
share their whole chain, so a preview of a thousand elements asked the platform
the same questions about the same ancestors a thousand times over.

A rebuild now threads one measure pass through the walk, and each node answers
once. On a 1689-element carousel that takes computed-style reads from 11,159 to
2,526 per rebuild and the rebuild itself from 59 to 12-18 ms per seek, with the
rendered marker set byte-identical.

The pass is deliberately not a cache. A cache would have to say what could have
changed since last time, and for a measurement the answer is anything: an image
finishing decode, a font swapping in, a transition frame, a container query, an
inserted stylesheet rule all move an element's box with nothing written to the
DOM and no record to invalidate on. A pass lives inside one synchronous
measurement that only reads, so nothing can move under it, and it is dropped
when the measurement ends. Every rebuild still measures every element.

Layout reads are untouched on purpose: each element still takes its own client
rect every rebuild, because that is the read that cannot be shared and must not
be remembered.

* test(studio): give one PropertyPanel case the file's own render timeout

Every other render test in that file passes RENDER_TIMEOUT_MS; this it.each was
left on vitest's 5s default and times out when the whole suite runs on a loaded
machine. Unrelated to any behaviour: it passes on either side of the change
when the machine is quiet, and fails in a full-suite run on either side when it
is not.

* refactor(studio): share the source-boundary walk and dedupe the rebuild fixtures

Three follow-ups from review of the measure pass, none of them behaviour:

The source boundary was memoized against the element that asked for it, so two
siblings still walked their whole chain separately and only the answer was
reused. It now memoizes every node on the way up, like the other two walks:
an element's boundary IS its parent's unless the element is one itself.

`isElementVisibleThroughAncestors` returns what it always returned, but it now
resolves top-down, so the set of nodes it takes a style read for is different
(smaller for a subtree hidden near the root). Three callers use it with no
memo, so that is written down next to it rather than left to be discovered.

The three rebuild fixtures in the indicator tests each carried their own copy
of the iframe/overlay/layout-stub scaffolding, which is four clone groups and
126 duplicated lines. One `mountPreview` helper now serves all of them and the
pre-existing selector-index fixture too.

* test(studio): pin the measure pass to one rebuild through an ancestor-derived input

The existing no-mutation-record test varies an element's own box, and the pass
never memoizes a box — so hoisting the pass to module scope, which is exactly
the mistake that would reintroduce cross-rebuild staleness, left every test
green.

This one fades a wrapper between two rebuilds and asserts the marker under it
goes away. Visibility through ancestors IS memoized, so the pass surviving the
rebuild serves the stale answer: with `createOverlayMeasurePass()` hoisted the
suite exits 1 on this test alone, and exits 0 at head.
2026-09-10 14:48:30 +00:00
Miguel Ángel 8bf5b4423e perf: sublinear Studio rebuilds and seeks (#3837)
* perf(studio): resolve the overlay coordinate basis once per composition

The iframe->overlay basis (composition root, root scale, iframe and overlay
rects) was resolved inside every geometry call, so measuring a preview cost one
querySelector("[data-composition-id]") plus three layout reads PER ELEMENT to
rediscover something that is a property of the composition and the canvas zoom.

It is now threaded through orientedGroupAwareOverlayRect, groupAwareOverlayRect,
orientedOverlayRect, orientedVisibleOverlayRect and toVisibleOverlayRect the way
toVisibleOverlayRects already batched it, and resolved once by the two callers
that measure many elements in one synchronous pass: the off-canvas indicator
rebuild and the overlay RAF loop.

Both passes only read the DOM, so nothing can move the canvas between two
measurements inside one of them.

* perf(studio): rebuild the layer walk from the mutation, not the document

A MutationObserver marked the off-canvas indicators dirty and the rebuild then
re-derived every element in the preview from scratch. The observer's loudest
source is inline style, which is what animation writes, so a composition just
sitting there re-derived the whole document several times a second, and each
element costs two getComputedStyle reads, two ancestor walks for its source
file, and a textContent read over its whole subtree.

The records now say WHAT to drop, not merely that something changed, and the
per-element derivations are memoized between rebuilds. Two lifetimes, because
they do not go stale together: whether an element renders (and how many layer
children it has) dies on any nearby attribute write, while how it is ADDRESSED
survives every style write and dies only on something that can renumber a
selector. That split is what makes an ordinary animation frame cost no document
queries at all.

Not attributable to specific elements, so still a full rebuild: nodes added or
removed, and any change to an identity attribute, which renumbers every element
sharing a selector.

Two supporting changes:

- getDirectLayerChildren asked getDomLayerPatchTarget for a full patch target
  per child and used it as a boolean. The target carries the selector's
  occurrence index, which is a whole-document query the yes/no does not depend
  on. isDomLayerElement answers it without one, for every caller. Its unused
  options parameter goes with it.
- The observer no longer filters attributes. An attribute it never hears about
  is one the cache would answer stale for, and the old filter omitted id and the
  data-composition-* attributes that decide a layer's identity. Widening only
  makes indicators refresh sooner; a rebuild is a pure read and is throttled
  either way.

Every other caller of collectDomEditLayerItems passes no cache and is unchanged.

* perf(runtime): visit only the clips a seek can flip

Every seek, and every frame of playback, rebuilt the window of every video and
audio element in the document and handed all of them to the per-clip sync loop.
On a composition of eighty videos that is eighty window derivations and eighty
per-clip passes to conclude that one clip is on screen.

A clip is active only while start <= t < end, so a clip whose window excludes
the new time is inactive there whatever its element state is. Sorting the
windows by each endpoint turns a seek into two binary searches: the clips whose
start or end lies between the old time and the new one, plus the ones that were
in window at the old time. Anything outside both was out of window before and is
out of window now, and was already paused and already evicted by the pass that
last saw it go out. A one-frame step visits the clip or two that flip; a jump
over a hundred clips still visits the hundred boundaries it crosses.

The index carries only the windows, and it rides the revision the duration
floors already ride: the same timing attributes, the same media metadata events,
the same timeline registry signature. Nothing else is cached. Every field handed
to syncRuntimeMedia is re-read from the element on every pass, because
el.duration can be reset under us by the load() retry, and a cached copy of it
would be exactly the stale duration the two-resolver-scope rule exists to
prevent.

The render/export path does not consult the index at all and visits every
element on every frame, and a render seek clears the sweep so a later live seek
cannot inherit a position it did not establish.

Also folds the duration floors' own invalidation into that shared revision.
Draining the observer is what detects a change, and only the first reader in a
task gets the records, so two caches each checking for themselves would have had
the second told nothing changed.

* refactor(studio): split the overlay basis and the layer-walk read into their own modules

Clears the 600-line file cap and the fallow audit on this branch. Behaviour is
unchanged: this moves code, it does not alter any of it.

File size. Both files were already near the cap on main and my three levers
pushed them over (615 vs 598, and 605 vs 584):

- domEditOverlayGeometry.ts -> 561. The iframe->overlay coordinate basis
  (OverlayRootScale, computeOverlayRootScale and the root/dimension lookups it
  owns) moves to domEditOverlayBasis.ts. It is the one piece of that file every
  other piece depends on, and nothing in it is about a single element's
  geometry. Its two callers now import it from there.
- domEditingLayers.ts -> 587. The per-element read that the walk performs moves
  to readDomEditLayerWalkEntry in domEditLayerWalkCache.ts, the module that owns
  the memoization it reads through. The walk keeps the traversal and the depth
  bookkeeping, which are the parts that are actually about walking.

No unrelated code was trimmed to make room.

Duplication, all three clone groups fallow flagged:

- The runtime seek fixture (createMockTimeline, the synchronous animation-frame
  clock, the CSS.escape shim, stubDuration) was copied between
  init.timingResolver.test.ts and init.mediaClipIndex.test.ts. It moves to
  runtimeSeekFixture.test-helpers.ts. Each suite keeps its own vi.mock calls,
  which are file-scoped and cannot be shared. The file is excluded from
  tsconfig.runtime.json alongside the test files it serves, for the same reason.
- domEditLayerWalkCache.test.ts repeated its mount/observe/first-walk setup in
  three tests; that is now withWarmWalk.

Complexity. Only one of fallow's three findings is attributable to this branch,
and fallow agrees: it marks the other two inherited and excludes them from the
gate.

- offCanvasIndicatorRefresh.ts update was NEW (absent from main's report, 10
  cyclomatic / 31.6 CRAP here). The optional-chain-and-default I added to drain
  the observer becomes drainPendingLayerMutations, with its own unit tests, and
  the function drops off the report entirely.
- useDomEditOverlayRects.ts update: 37 cyclomatic / 69 cognitive on main AND
  here. My change added one statement and no branch; the function grew 139 -> 147
  lines, which is what resurfaced it. Left alone.
- domEditingDom.ts escapeCssIdentifier: 24 cyclomatic / 19 cognitive / 148.4
  CRAP on main AND here. Untouched by this branch; only its line number moved
  (173 -> 182) because the composition-source-map revision counter sits above it.
  Left alone.
2026-09-10 03:55:28 +00:00
Miguel Ángel ac27b1534f perf(studio): resolve a selector's occurrence index once per layer walk (#3831)
The preview flattens several composition files into one DOM, so a layer's
identity is its selector plus its occurrence index WITHIN its own source
file. `getSourceScopedSelectorIndex` derived that per element: a whole
-document `querySelectorAll(selector)`, `resolveSourceFile` on every match,
then `indexOf`. Every element sharing a class paid for all of them, so a
walk over n such elements did n whole-document queries and n^2 source-file
resolutions — the shape a composition of repeated cards or tiles has by
construction.

Build the occurrence index ONCE per selector instead and share it across
one walk. `withSelectorIndexPass(doc, run)` opens that scope; outside it
the helper behaves exactly as before, per call.

The pass lives in `collectDomEditLayerItems`, which owns the loop, rather
than at a call site — its four callers (off-canvas indicators, the layers
panel, the marquee hit-test and the agent look tool) all walked the same
way and all paid the same cost.

Behaviour is unchanged. The occurrence indices are identical, including
the misses: an element outside the requested source file, or one not
matching the selector, still yields undefined, as do `#`-prefixed and
`[data-composition-id=` selectors and an invalid selector.

Measured on a 1689-element preview over 80 single-frame seeks, per rebuild:
class-selector document queries 171.6 -> 10.8, and the walk's self-timed
cost 15.25ms -> 5.37ms. Elements walked per rebuild is unchanged at 973.7,
so the two arms did the same work.

Tests assert complexity invariance rather than a threshold: the query count
must be IDENTICAL at n and 4n elements sharing a selector, which a fixture
-sized threshold would not catch. Both fail on the previous algorithm.
2026-09-09 19:11:22 -07:00
James Russo 4f77c282da fix(studio): authenticate preview message senders (#3812) 2026-09-09 09:32:03 -04:00
James Russo f54ba56136 fix(studio): contain project IDs across client and server routes (#3808)
* fix(studio): contain project IDs across client and server routes

* test(studio): use a portable project directory fixture

* fix(studio): reject drive-relative IDs and filter project discovery
2026-09-09 05:27:36 -04:00
Miguel Ángel ab07d67380 fix(studio): preserve timeline DOM identity during hydration (#3681) 2026-09-08 18:08:55 +00:00
James Russo 7d7003aa64 fix(studio): match style attributes with explicit quote boundaries (#3712)
* fix(studio): match style attributes with explicit quote boundaries

* fix(studio): apply quote boundaries to active source writers
2026-09-05 14:50:27 -04:00
James Russo c564daf210 fix(studio): avoid inline style regex backtracking (#3708) 2026-09-05 12:58:26 -04:00
miga-heygen 00c575d23b fix(studio): prevent preview hang on burst external file rewrites (#3648)
* fix(studio): prevent preview hang on burst external file rewrites

Two interacting bugs caused Studio to freeze when multiple processes
(generator, check, snapshot) burst-wrote index.html within seconds:

1. SSE listener leak: the /api/events handler added a watcher listener
   per client connection but never removed it on disconnect. Reconnects
   accumulated dead listeners, each triggering readFileSync on every
   file change and writing to closed streams.

2. Generation starvation: processChange incremented generationRef and
   awaited drainPendingChanges. A second event arriving mid-drain bumped
   the generation, causing the first drain to bail at the generation
   check. With rapid writes, no drain ever completed and Studio stayed
   frozen on stale content.

Fix 1: use stream.onAbort() to remove the watcher listener when the
SSE connection closes.

Fix 2: gate processChange with a draining ref. While a drain is in
progress, stash the latest event. On completion, process the stashed
event — the last write in a burst always completes its reload.

Closes #3646

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

* fix(studio): align coordinator tests with drain serialization

Update existing test to expect the new behavior: when two events
fire in quick succession, the first drain completes and triggers a
reload (previously it was silently discarded). The stashed event
then starts a second drain.

Also fix the burst-write test to use the drains array pattern and
explicit act() flushes for stashed event processing.

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

* fix(studio): stash events with allowDuplicate and harden listener cleanup

Address Rames's review findings:

- Stash with allowDuplicate: true so re-dispatched events are not
  swallowed by the duplicate guard (the identity was already written
  on the way in, so the stashed event matched itself on re-entry).
- Wrap SSE keepalive loop in try/finally so the listener is removed on
  both abort and throw, not just abort.
- Restore stale-completion guard test coverage lost in the rename.
- Use await act(async () => {...}) for burst dispatches so assertions
  depend on the stash guard rather than scheduling.

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

* fix(studio): simplify drain serialization and restore SSE cleanup

Restructure processChange into intake + drain loop:

- processChange is now synchronous — validates, dedupes, checks own
  echoes, enqueues the accepted payload, and starts the drain loop
- startDrainLoop runs while the pending slot is non-null, draining
  one event per iteration via drainOnePending
- No recursive void processChange(...) from finally, so no
  allowDuplicate escape hatch needed — stashed events never re-enter
  intake guards

SSE listener: restore stream.onAbort alongside try/finally. Hono's
sleep() never throws, so finally alone doesn't fire on disconnect.
Both paths call removeListener (Set.delete is idempotent).

Tests: remove stale-drain test that contaminated subsequent tests by
emptying the shared roots array mid-test. Use sync act() for burst
dispatches — the stash decision is synchronous.

All 10 coordinator tests pass locally (NODE_ENV=test).

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-09-04 21:44:25 -04:00
James Russo 3bc46a8ade fix(studio-server): cascade GSAP cleanup when deleting subtrees (#3655) 2026-09-04 17:38:33 -04:00
Miguel Ángel 81f1a903e1 fix(studio): rebind paused preview after live edits (#3596) 2026-09-02 10:23:09 -04:00
Miguel Ángel 6b5b4cb988 feat(studio): make agent edits live and explicit (#3581) 2026-09-02 01:11:13 -04:00
Miguel Ángel 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
Felipe Caldas f18964de0c fix(studio): let a hidden sub-composition child be shown again (#3559)
The eye on an expanded sub-composition child always rendered as "Hide",
whatever the source said. One click hid the element and every click after
that rewrote the same attribute, so the row could never be shown again,
not even after a reload, since data-hidden is in the file.

buildChildElements synthesizes a child row from a manifest clip with no
element to read, and compensated by inheriting hidden/timelineLocked/
timelineRole/fxChain/automation from the child's flat store twin. That
twin does not exist for a real sub-composition: processTimelineMessage
drops any clip whose parent composition is itself in the manifest before
building the flat store, so the lookup always missed and the inheritance
was dead code for the one case it was written for. It worked only for a
phantom-wrapper parent, where the child does keep a store entry.

Read the state off the live element instead. collectSubCompositionHostState
walks each sub-composition host in the preview document and records the
data-* state of every id'd descendant, keyed by dom id. The existing
sibling walk cannot serve this: it defines which rows exist and writes
parentMap, and it stops at the first id'd descendant, so scene footage
sitting one level below an id'd region wrapper is never reached. The new
walk descends the whole subtree and touches neither rows nor parentage.

Reproduced on a 9-scene storyboard project where every scene is a
sub-composition. Before: a scene video and title carrying data-hidden both
announced "Hide track N", and clicking left the file byte-identical. After:
both announce "Show track N", and hide/show round-trips the attribute.
A top-level clip with the same attribute always announced "Show", which is
what made the gap specific to expanded child rows.

The existing regression test passed throughout because its fixture hands
the child a flat twin with hidden: true and gives the host no
compositionSrc, so the child key falls back to the index.html scope and a
twin can exist. The added test models a real sub-composition instead.
2026-08-31 17:23:47 +00:00
Miguel Ángel 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
Miguel Ángel 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 28be8dddfa fix(studio): correct save failure telemetry (#3499) 2026-08-30 00:24:46 -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
rajanpanth adf9b0ccee fix(studio): activate a composition at any path, not just compositions/
The Comps panel sets activeCompositionPath to the selected file, but
useCompositionStack's effect only pushed a stack level when that path
started with compositions/. A project laying its comps out anywhere
else, for example a generated multi-part build with parts/part-1.html
next to the root index.html, matched no branch at all: the row
highlighted and the URL hash updated while the stack silently kept the
master mounted, so the canvas and timeline stayed on index.html and any
edit landed in the root file instead of the part.

Replaced the prefix test with a plain truthiness check, so the root
stays on the master level and every other path pushes its own level.
Label derivation is unchanged, matching CompositionsTab's own
convention.
2026-08-29 20:50:16 +05:45
miga-heygen da6514d458 fix(studio): update WebMCP test for polyfill fallback
The "registers nothing when the browser has no WebMCP" test asserted
that document.modelContext was absent after mount. Since #3514 added
the @mcp-b/global polyfill fallback, the hook now installs
document.modelContext even when the browser has no native support —
that is the polyfill's job. The real assertion is that mounting does
not throw, which still holds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-28 03:34:28 +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
Vance Ingalls 4f00336c92 feat(player): add retained runtime data channels (#3471) 2026-08-26 07:48:00 +00:00
James Russo 9aaa7552fc fix(studio): dedupe repeated selection telemetry (#3498) 2026-08-25 22:58:37 -07: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 acc6898255 fix(core): address review — gate early diagnostic, fix empty crossOrigin, document gaps 2026-08-25 05:25:24 +00:00
Vance Ingalls 7caf4b8871 feat(studio): drag automation segments (#3465)
* feat(studio): drag automation segments

* fix(studio): clear clip selection for group effects

* fix(studio): replace clip selection with audio bus

* fix(studio): make audio bus selection authoritative
2026-08-24 12:44:05 -07:00
Vance Ingalls 05affaae21 feat(studio,core)!: remove solo and the group meter (#3454)
* feat(studio,core)!: remove solo and the group meter

* docs(audio): keep removal rationale current

* refactor(core): retire studio solo bridge
2026-08-23 19:19:08 -07:00
Vance Ingalls 0c274f7e57 fix(studio): reconnect property-panel audio controls (#3453)
* fix(studio): reconnect property-panel audio controls

* fix(studio): unify property panel audio detection

* fix(studio): satisfy panel and deletion gates
2026-08-23 19:04:22 -07:00
Vance Ingalls f575bdadcb fix(studio): harden carve and FX rack behavior (#3452)
* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional

* fix(studio): keep preview state synchronized

* fix(studio): align audio rows, automation lanes and headers

* fix(studio): stabilize timeline audio derivations

* refactor(studio): simplify group metadata memoization

* style(studio): keep timeline layout within size gate

* fix(studio): keep timeline preset apply off auditions

* fix(studio): harden carve and FX rack behavior

* fix(studio): repeat audio FX reveal requests
2026-08-23 18:51:07 -07:00
Vance Ingalls 4ea018a4a7 fix(studio): align audio rows, automation lanes and headers (#3451)
* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional

* fix(studio): keep preview state synchronized

* fix(studio): align audio rows, automation lanes and headers

* fix(studio): stabilize timeline audio derivations

* refactor(studio): simplify group metadata memoization

* style(studio): keep timeline layout within size gate

* fix(studio): keep timeline preset apply off auditions
2026-08-23 18:48:08 -07:00
Vance Ingalls 89069d24c3 fix(studio): keep preview state synchronized (#3450)
* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional

* fix(studio): keep preview state synchronized
2026-08-23 18:10:46 -07:00
Vance Ingalls 8e96ccb0b2 fix(studio): make audio-group edits transactional (#3449)
* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional
2026-08-23 18:10:19 -07:00
Vance Ingalls 0f302285a2 fix(studio): unify audio IDs and group state (#3448)
* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state
2026-08-23 18:09:58 -07:00
Vance Ingalls dd0626a55a fix(studio): stop the grouping dialog opening off the bottom of the window (#3421)
Reported as "the grouping button did nothing — I clicked it and nothing
happened". The dialog WAS opening. It positioned itself at
`anchorRect.bottom + 4` with no flip and no clamp, and this button lives in a
track header at the bottom of the studio window, so it opened past the viewport
edge. It was the last floating surface in the timeline with no viewport handling
at all.

It now goes through `resolveFloatingPanelPosition`, the helper the other body
portals already position with (`RenderQueue`, `propertyPanelColor`), so it flips
above the anchor when there is no room below and clamps so neither edge leaves
the viewport. `GROUP_DIALOG_SIZE` is a declared estimate in the same style as
`FORMAT_PANEL_SIZE` and `COLOR_PICKER_SIZE`: `w-56` is exact, only the flip
decision reads the height, and the clamp keeps the dialog on screen either way.

Two tests, at a realistic bottom-of-window anchor and hard against the right
edge. Both verified to fail against the raw positioning.

Worth noting why this shipped: the existing `group-pointer` test passes with or
without the fix. happy-dom reports an all-zero rect for an unlaid-out button, so
the dialog landed at top:4 — on screen, and nothing like the real app. A geometry
test that never sets a geometry proves nothing.

Deliberately NOT included: a toast for the grouping write's silent
`elements.length < 2` bail. That path is real in code but I could not reach it
from the UI — the button only renders on a track with 2+ ungrouped clips, and
sub-composition audio arrives as separate single-clip rows, so the offer never
appears there. Adding a message for an unreachable branch, plus the file split it
would force to stay under the 600-line studio cap, is not justified by evidence.
2026-08-22 17:04:45 -07: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
Vance Ingalls c594023895 fix(studio): give the group row's caret the panel's glyph and size back (#3415)
* fix(studio): put the timeline's portaled surfaces on the tier the other portals use

The FX popover, the grouping dialog it swaps for, and the automation selection
menu are all portaled to `document.body`, so they land in the root stacking
context — where they sat at `z-50` while the app's own chrome occupies 60, 90,
91, 92, 94, 100 and 110, and every other portal that has to clear that chrome
(`Tooltip`, `AssetContextMenu`, `InlineTextToolbar`, `RenderQueue`) already uses
`z-[200]`. These three were the odd ones out.

Scoped honestly: the clipping in the report is fixed by the height cap in the
previous commit, which is what actually cut the popover off at the timeline
chrome. This commit is tier consistency — it removes the standing risk of a
portaled timeline surface losing to any of those seven higher tiers, rather than
a demonstrated repro. Confirm against a real window before claiming more.

* fix(studio): move the remaining body-portaled context menus to the same tier

The all-sites audit in review was right and the previous commit did half the set.
Using `createPortal(…, document.body)` as the predicate rather than the timeline
directory, four more surfaces sit in the root stacking context at `z-50` below
the seven chrome tiers (60, 90, 91, 92, 94, 100, 110):

- `player/components/ClipContextMenu.tsx:51`
- `player/components/TrackGapContextMenu.tsx:78`
- `player/components/KeyframeDiamondContextMenu.tsx:99`
- `components/editor/CanvasContextMenu.tsx:215`

The fourth is the easy one to miss — it is the only one outside
`player/components/`, so a timeline-scoped sweep finds exactly the other three.
It belongs to the same set by its own account: its className is byte-identical
to `ClipContextMenu`'s and its header comment says it mirrors that file's look,
positioning, and dismiss behaviour, portaled to `document.body`.

Two body portals deliberately left alone. `sidebar/BlocksTab.tsx:125` portals
`PromptPreviewModal`, which carries its own `z-[100]`/`z-[110]` modal tier — a
`z-` class on the portal wrapper would be dead weight. `RenderQueue.tsx:235` is
already `z-[200]`. `FileTree.tsx:336` and `FileTreeNodes.tsx:103` are `fixed
z-50` but are NOT portaled — they render inside the sidebar's own stacking
context, so the root-context argument does not reach them and raising them would
be an unrelated change.

Crossing the `z-[100]`/`z-[110]` modal backdrops is unreachable for the same
reason it was for the first three: all four dismiss on an outside pointerdown,
so the press that opens a modal closes the menu first.

`CanvasContextMenu.test.tsx:95` asserted on `.fixed.z-50` to prove the menu did
NOT render; left as-is it would have passed vacuously against any tier. Updated
to the new class so it still fails if the menu renders.

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

* fix(studio): correct the interval in the popover in-bounds test comment

`bottom: 32` with `maxHeight: 160` in a 200px viewport puts the box at y = 8..168,
not y = 8..40 — the bottom edge sits at `innerHeight - bottom`, and the comment
read it as the height instead. The assertions below already computed the right
geometry; only the stated interval was wrong, on a regression test whose comment
is the next reader's model of what it pins.

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

* fix(studio): give the group row's caret the panel's glyph and size back

The timeline group row was the only disclosure caret in the studio still drawn as
a rotated 11px non-mono glyph. Both of the property panel's carets
(`hf-fx-preset-run-caret` in propertyPanelFxPresetRun, and
propertyPanelFxNodeOpenBody) swap between ▸ and ▾ in `font-mono`, so the same
affordance was rendering smaller and differently on the row than in the panel it
opens.

Now mono, a size up, and swapped rather than rotated — a rotated ▸ also sits
off-centre in its box because the glyph is not square.

Three tests, mounting the header: the swap, the absence of a rotate transform,
and the mono/size class. Verified all three fail against the previous caret.

* fix(studio): stop the caret comment and test name claiming a size match

Both reached past what was actually verified, and the comment is the part that
stays in the tree.

The comment said the caret matches the property panel's carets and "should not be
smaller here than it is there". Inverted for one of the two: the node-body caret
sits under `text-[9px]` (`propertyPanelFxNodeOpenBody.tsx:240`), so at 13px this
one is materially larger, and `hf-fx-preset-run-caret` has no size rule of its
own — its rendered size is unmeasured. Narrowed to the two claims that hold:
mono, and swapped rather than rotated.

The third test was named "matches the property panel's carets" but reads only
this component's own className, so the panel carets could move and it would stay
green. Renamed to what it pins.

No behaviour change.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 05:17:31 -07:00
Vance Ingalls 8612cfd40f fix(studio): put the timeline's portaled surfaces on the tier the other portals use (#3414)
* fix(studio): put the timeline's portaled surfaces on the tier the other portals use

The FX popover, the grouping dialog it swaps for, and the automation selection
menu are all portaled to `document.body`, so they land in the root stacking
context — where they sat at `z-50` while the app's own chrome occupies 60, 90,
91, 92, 94, 100 and 110, and every other portal that has to clear that chrome
(`Tooltip`, `AssetContextMenu`, `InlineTextToolbar`, `RenderQueue`) already uses
`z-[200]`. These three were the odd ones out.

Scoped honestly: the clipping in the report is fixed by the height cap in the
previous commit, which is what actually cut the popover off at the timeline
chrome. This commit is tier consistency — it removes the standing risk of a
portaled timeline surface losing to any of those seven higher tiers, rather than
a demonstrated repro. Confirm against a real window before claiming more.

* fix(studio): move the remaining body-portaled context menus to the same tier

The all-sites audit in review was right and the previous commit did half the set.
Using `createPortal(…, document.body)` as the predicate rather than the timeline
directory, four more surfaces sit in the root stacking context at `z-50` below
the seven chrome tiers (60, 90, 91, 92, 94, 100, 110):

- `player/components/ClipContextMenu.tsx:51`
- `player/components/TrackGapContextMenu.tsx:78`
- `player/components/KeyframeDiamondContextMenu.tsx:99`
- `components/editor/CanvasContextMenu.tsx:215`

The fourth is the easy one to miss — it is the only one outside
`player/components/`, so a timeline-scoped sweep finds exactly the other three.
It belongs to the same set by its own account: its className is byte-identical
to `ClipContextMenu`'s and its header comment says it mirrors that file's look,
positioning, and dismiss behaviour, portaled to `document.body`.

Two body portals deliberately left alone. `sidebar/BlocksTab.tsx:125` portals
`PromptPreviewModal`, which carries its own `z-[100]`/`z-[110]` modal tier — a
`z-` class on the portal wrapper would be dead weight. `RenderQueue.tsx:235` is
already `z-[200]`. `FileTree.tsx:336` and `FileTreeNodes.tsx:103` are `fixed
z-50` but are NOT portaled — they render inside the sidebar's own stacking
context, so the root-context argument does not reach them and raising them would
be an unrelated change.

Crossing the `z-[100]`/`z-[110]` modal backdrops is unreachable for the same
reason it was for the first three: all four dismiss on an outside pointerdown,
so the press that opens a modal closes the menu first.

`CanvasContextMenu.test.tsx:95` asserted on `.fixed.z-50` to prove the menu did
NOT render; left as-is it would have passed vacuously against any tier. Updated
to the new class so it still fails if the menu renders.

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

* fix(studio): correct the interval in the popover in-bounds test comment

`bottom: 32` with `maxHeight: 160` in a 200px viewport puts the box at y = 8..168,
not y = 8..40 — the bottom edge sits at `innerHeight - bottom`, and the comment
read it as the height instead. The assertions below already computed the right
geometry; only the stated interval was wrong, on a regression test whose comment
is the next reader's model of what it pins.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 05:01:48 -07:00
Vance Ingalls 277fe0fa22 fix(studio): cap the timeline FX popover to its gap, and scroll the list inside (#3413)
* fix(studio): cap the timeline FX popover to its gap, and scroll the list inside

The popover grew to whatever the preset list needed, so on a short window it ran
off the top or the bottom of the viewport and took its footer ('+ effect' /
'Open rack') with it — nothing scrolled, so the presets past the edge were
simply unreachable.

It now caps to the space on whichever side it opens toward, and the preset list
scrolls inside that while the footer stays put. `min-h-0` on the scroller is
load-bearing: a flex child defaults to min-height:auto and would refuse to
shrink, pushing the footer out instead of scrolling.

`spaceAbove` is named for the cap's benefit; it equals `anchorRect.top`, so the
flip condition is unchanged.

Four tests cover it, because this shipped once before with none: the downward
cap, the upward cap, the usable-minimum clamp, and the footer being a sibling of
the scroller rather than inside it. Verified they fail without the cap.

* fix(studio): slide the FX popover in-bounds instead of hanging it off the edge

Review found the minimum defeating the viewport cap: `Math.max(MIN_POPOVER_HEIGHT,
available)` kept the box 160px tall even when the chosen gap was smaller, so the
box extended past the edge it opened away from. At 200px of viewport with the
anchor at 100..120 it flipped up to `bottom: 104px` and spanned y = -64..96 —
every preset still reachable, but through a ~57px window with the top third of
the dialog off-screen. Reachable at high browser zoom, not only in a synthetic
short window: `available` drops under 160 once the gap is under ~172px, which
400% zoom on a 1080p display produces on both sides.

Shrinking to the gap would undo the floor on purpose (a 20px gap gives a 20px
popover — the vanishing popover in a new costume), so honour the floor and clamp
the resulting box into the viewport the way `left` already is. Two parts:

- Cap the floor by the window itself (`innerHeight - 2 * VIEWPORT_MARGIN`). The
  minimum is a floor against a tight gap, not against a tight window; below
  176px of viewport, physical space has to win.
- Inset the `top` / `bottom` offset to `innerHeight - height - VIEWPORT_MARGIN`,
  so a floor larger than the gap slides the box back in rather than off the top.

The tight case now lands at `bottom: 32px` with `maxHeight: 160px` — the box at
y = 8..40, one margin on each side. The two ordinary cases are unchanged
(34/726 down, 72/688 up), which the existing tests pin.

Tests: two added — both edges in-bounds when the minimum exceeds the gap, and
the floor yielding when the whole window is shorter than it. Both fail on the
previous arithmetic (104px vs 32px, 160px vs 104px). The three pre-existing
geometry tests now pin `window.innerHeight` through one shared helper instead of
inheriting happy-dom's 768 default, so their expected numbers are derivable from
the test and immune to a dependency bump.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 04:39:24 -07:00
Vance Ingalls 0e9a4f371d feat(audio): open the audio FX, group and mute features to everyone (#3401)
* feat(audio): open the audio FX, group and mute features to everyone

The twelve-PR audio stack landed on main with all three of its canaries still
at 0%, so the FX rack, the group rows, mute and solo are in the build and
reachable by nobody. This removes the gates rather than raising the numbers: a
canary that gates nothing is a branch every future reader has to evaluate.

Gone:
- the `audio-fx-rack`, `audio-track-mute` and `audio-groups` registry entries;
- the five studio gates they fed — the Audio FX section in `PropertyPanelFlat`,
  the mute label, the muted strike-through and the solo button in
  `TimelineTrackPlainHeader`, and the group-row derivation in
  `useTimelineTrackDerivations`. Each feature now renders on its own
  precondition (an audio track, a grouped track) exactly as it did for an
  enrolled user.

The old test pinned `audio-fx-rack` at 0% and asserted it was registered, which
is the opposite of what should hold now. Replaced with a pin that no
`audio-*` canary exists at all: re-registering one silently re-hides a shipped
feature, and nothing else in the tree would say so. Verified it fails when one
is added back.

The equivalent removal on wa-25-review-fixes (#3363) can no longer land — that
branch is 105 commits and 310 files divergent from main now that the stack has
squash-merged past it.

* docs(audio): retire the last references to the audio canaries

Two leftovers the gate removal did not reach.

`TimelineTrackPlainHeader.tsx` still said "Gated: the relabel ships behind the
canary, unlike the preview fix" above the function that picks Mute vs Hide.
Nothing gates it now, so the comment asserted the opposite of the code.

`docs/weekly-updates.mdx` is published, and it told readers the audio work is
"staged behind a canary at zero percent, so none of it is visible by default"
and to "set `HF_CANARY_AUDIO_FX_RACK=on` to use the rack today". That env var
maps to no registry entry any more, so following the instruction does nothing
at all. The week's record stays — it is a dated entry — but it now says the
rollout completed and that the variable is inert.

* fix(studio): name the mute action per track, and pin the newly-live audio rows

Review findings on the canary removal. All three are in code the 0% gate made
unreachable, so this is the first time any of it runs for a user.

*blocker* — `visibilityButtonLabel`'s audio branch returned "Muted" / "Mute":
the current STATE rather than the action, so nothing told a screen-reader user
that activating an already-muted row would unmute it, and it dropped `suffix`,
so every audio row shared one accessible name. Music plus VO is the ordinary
case, which makes that two identical buttons. Now `Unmute track N` /
`Mute track N`, matching the wording `timelineTrackVisibility` already writes
into undo history for the same click. `showAsMute` also picks the icon, so this
is the control's whole identity, not a tooltip.

Tests, for paths that had never executed enabled — a canary at 0% returns
`out_of_cohort` before bucketing, and studio additionally excludes
`navigator.webdriver`, so no suite could reach them:

- `VisibilityButton` — both audio states, two rows staying distinguishable, the
  visual branch unchanged, and the callback still taking the real track key
  rather than the display row. Fails on the old label.
- `useTimelineTrackDerivations` — an ungrouped project stays in raw ascending
  order with no groups, and an interleaved group's members become contiguous
  under an anchor at `memberTracks[0] - 0.5` while the ungrouped track between
  them keeps its place. Plus label/volume/mute mirroring and the id fallback.

Also pins the three retired canary names individually rather than by prefix:
`audio-fx-rack` coming back is caught either way, but `fx-rack` escaped a
`startsWith("audio-")` check. The family guard stays alongside it.

* fix(studio): record the row the mute button announced, not a second derivation

Review finding: the header's track number and the undo-history label's are
computed from two different orderings, and un-gating `audio-groups` is what
makes them diverge.

The header's row comes from the group-aware list — `groupTimelineTracks` emits a
synthetic anchor row per group and pulls members contiguous. The history's comes
from `timelineTrackOrder`, a plain ascending sort of element-bearing keys with no
anchors. On the fixture in this PR's own derivation test, grouped order
`[-0.5, 0, 2, 1]` against ascending `[0, 1, 2]`: clicking mute on the group's
first member said "Mute track 2" and recorded "Mute track 1". Off-cohort this
could not happen — the old branch returned raw tracks, so both sides sorted the
same way.

`onToggleTrackHidden` now carries the display row the clicked control rendered,
and `toggleTimelineTrackHidden` prefers it over deriving its own. One number
instead of two derivations, which is what `timelineTrackDisplay`'s "one owner of
what track number does the user see" already promised. The callback still acts on
the real fractional key, so nothing muted the wrong row before or now — only the
announced and recorded row was wrong.

Also pins the rest of the newly-live surface: the solo button's presence and
pressed state, its absence on a visual track, and the strike-through for both a
row's own mute and a group mute (with the title that says which). Three existing
call-site assertions now check the threaded row too.
2026-08-21 18:22:40 -07:00
Vance Ingalls 6a92d21401 feat(studio,core): reach presets and the rack from the timeline (#3292)
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).

The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).

The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).

Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
  (data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
  (timelineElementFxAttribute.ts), addressed by the TimelineElement
  itself rather than the current selection. This is the one real
  architectural gap the step doc's assumption didn't survive: the
  property panel's onSetAttributeQuiet closes over domEditSelection,
  so writing a clip that isn't already selected has no synchronous
  path through it. Extracted the shared live-patch-then-persist core
  (persistElementAttribute, timelineEditingHelpers.ts) out of both
  this new path and the existing setAudioGroupAttribute, which the
  fallow duplication gate flagged as a 66-line clone on first pass —
  now a single ~50-line core parameterized by patchLive/readLive, with
  each caller a ~15-line wrapper resolving its own patch target
  (buildPatchTarget({domId}) for a group, buildPatchTarget(element)
  for an arbitrary clip) and live-DOM lookup.

Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.

Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.

Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 12:01:34 -07:00
Vance Ingalls 254de3d1c4 fix(studio): captions UX — mode exit, undo, autosave surfacing, honest gating (#1968)
Caption-editing fixes from the studio UX review. This surface held five of
the thirteen criticals; the theme is that the editing UI shipped ahead of
its apply/persist pipeline, so several controls mutated an in-memory model
with no downstream effect, and the mode itself could never be exited.

Mode trap: caption edit mode auto-activated on detection and had no exit —
`setEditMode(false)` and `reset()` had zero call sites, so the caption
overlay replaced normal element editing for the rest of the session, even
after switching compositions. The store now resets on composition change
(flushing the last debounced edit first), an "Editing captions · Exit" pill
sits on the preview, and a re-enter button appears once dismissed.

Honest gating of dead surfaces: the Animation tab (31 presets ×
duration/ease/stagger/intensity) edited state that was never applied to
playback nor serialized — wiring it needs a CaptionOverride schema
extension in packages/core plus a runtime engine, so the tab is now visibly
disabled with an amber "isn't applied to playback or saved yet" notice
instead of silently discarding work. Timing edge-drags moved a block that
never changed playback and never saved; the handles are gone and the blocks
remain as select/seek targets. Double-click split desynced the overlay↔DOM
index mapping, so split is out until regeneration exists.

Undo: store-level undo/redo (cap 50, 800ms coalescing by edit target)
across all ten mutations, with ⌘Z/⇧⌘Z intercepted while caption mode is
active and reapplied to the live iframe. Previously ⌘Z reverted an
unrelated file edit while the bad caption drag persisted.

Autosave: save failures, including non-2xx, raise a persistent "not
saved — Retry" banner; the code's own comment called this a data-loss path
and it was telemetry-only. Debounced saves flush on unmount instead of
being discarded, `beforeunload` flushes and warns while pending, and
corrupt overrides JSON is distinguished from a missing file.

Input safety and a11y: arrow-key nudge no longer hijacks arrows inside
form inputs; numeric fields commit finite values only (typing "-" used to
inject NaN into gsap and persist null); "Mixed" shows on multi-select
divergence; Escape cancels an in-flight drag and restores the pre-drag
transform; ⌘A selects all; caption blocks are keyboard-selectable with a
playhead line and click-to-seek (CaptionTimeline's `onSeek` prop existed
but nothing passed it); 24px hit areas around the 8px handles; a hint when
no boxes are visible; visible input focus styles; tablist semantics.

Perf: the 66ms getBoundingClientRect polling loop is replaced with
event-driven updates (player-store subscription, preview messages,
ResizeObserver, rAF-coalesced); the interval now runs only during playback.

Reconciled against main: StudioPreviewArea.tsx was deleted by the Studio
revamp (#2291), so the mode pill, the sync-error banner and the re-enter
button move to its successor, nle/PreviewOverlays.tsx, and the caption
track's onSeek is wired in EditorShell. The per-keyframe
onChangeKeyframeEase change that also lived in that file is dropped:
main removed the prop, and #1967 now routes the diamond menu's ease action
to the focused-ease-segment editor instead.

Restacked onto main now that PRs 1962-1967 have squash-merged, so this
carries only its own changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:37:28 -07:00
Vance Ingalls 0d26072e6c feat(studio,core): mute groups, and hear-only-this that cannot reach the export (#3291)
B5: mute and solo, on groups and tracks (track mute already shipped by A2 —
nothing to build there).

Group mute — persisted as data-hidden on the <hf-audio-group> element itself
(never written onto members, per design doc §2.1's state-restoration
warning). Studio action reuses B7's generic setAudioGroupAttribute
(setQuiet/setLive split) rather than duplicating toggleTimelineTrackHidden's
shape — same one-atomic-patch/one-undo-entry contract, already built for
exactly this purpose. Render: B4 already drops every member of a
data-hidden group (confirmed by a new audioMixer.test.ts case — no
production change needed there). Preview: a dedicated muteGain node
(groupInput -> [fx] -> muteGain -> output -> master) so a mute toggle
never fights scheduleVolumeLane's ramps on the same param — the same
hazard B7's volume fader was split out to avoid. Mid-playback toggles
sync via a new syncAudioGroupMute pass in init.ts (a group carries no
data-start, so it's invisible to the existing visibility-node query).
Members of a muted group render the strikethrough label treatment
(TimelineTrackPlainHeader's isGroupMuted, sourced from
TimelineElement.audioGroupHidden) — display only, no attribute touched.

Solo — "Hear only this": a new session-only store slice (audioSoloSlice,
soloed: ReadonlySet<string> of clip/group ids, never track numbers, never
serialized). Predicate (isAudibleUnderSolo, packages/core/src/audioGroups.ts
so both the store and the preview transport share one definition): an
element is audible while any solo is active only if it or its own group is
soloed. "Siblings, never ancestors" lives in the graph, not the predicate —
solo gain is a per-element stage only; group buses are never attenuated by
solo, so a soloed member's path through its group stays open by
construction. Preview: a dedicated per-element soloGain in
webAudioTransport.ts (parallel to the mute mechanics), pushed via
window.__hf.setAudioSolo — a direct call, not an attribute write, so it
can't ride the visibility-diff path mute uses. media.ts's HTMLMedia
fallback folds the same predicate into its per-tick volume computation
(the same seam A2 used for data-hidden). Half-lit group indicator
(isGroupHalfLitUnderSolo) for "not soloed itself, but a member is".
Exclusive-by-default toggle, ⌘/Ctrl-click to add/remove, TimelineSoloButton
(⌗) beside mute on both track and group headers. Transport-bar banner
("Hearing only <label> — your export is not affected", Clear button) added
in PlayerControls.tsx, reading labels straight off the live preview DOM.

Export-safety, the most important property here: toggling/adding/clearing
solo never calls setAttribute/removeAttribute on any element and never
invokes the project save path (both asserted directly via spies in
audioSoloSlice.test.ts) — solo cannot reach an export by construction, not
by convention.

Also: extracted useHydrateActiveCompPathFromUrl out of App.tsx (a
pre-existing, unrelated effect) to stay under the 600-line filesize cap
after wiring useAudioSoloBridge in; and fixed a circular dependency the
solo-banner wiring introduced (useAudioSoloBridge.ts now imports
usePlayerStore from its concrete module instead of the player/ barrel,
which re-exports PlayerControls.tsx — the barrel path is what closed the
cycle).

Gates: bun run build clean; packages/core full suite 2379/2379; packages/
studio full suite 4276/4294 (18 pre-existing todo); packages/engine
audioMixer.grouping.test.ts 5/5; oxfmt/oxlint clean on all 23 touched
files; fallow clean (0 new circular deps, 0 new filesize/complexity
findings).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 11:19:53 -07:00
Vance Ingalls a01a5d7b3c fix(studio): player UX — honest waveform, keyframe menu actions, beat-delete gesture (#1967)
Player and timeline fixes from the studio UX review, reconciled against six
weeks of main.

Honest media states:
- AudioWaveform no longer falls back to synthesised sine-wave peaks when a
  decode fails. The failure propagates and the clip renders a dashed flat
  line + "waveform unavailable" instead of a plausible waveform an author
  would trim and beat-align against. Main's thumbnail scheduler already
  caches the failure with a TTL, so this neither refetch-loops nor pins the
  degraded state past a transient error.
- VideoThumbnail renders a static "no preview" placeholder on a failed
  decode rather than resolving to an empty box.

Keyframe context menu, restored:
- "Edit Ease…" (showing the current ease) and "Copy Properties" (async,
  "Copied!"/"Copy failed") were plumbed but never rendered. Edit Ease routes
  to the same focused-ease-segment path a segment click takes, so the menu
  advertises the editor that exists instead of growing a second one; it is
  offered only for a keyframe that names a tween to focus. Copy Properties
  matches the keyframe cache on clip-% with the same tolerance main's
  move-to-playhead uses. Every row is a role="menuitem" with arrow-key
  navigation and focus handling via the new useMenuKeyboardNav helper, and a
  separator now isolates "Delete All Keyframes" from the single delete.

Error prevention:
- Beat dots: hit target 12→24px (WCAG 2.5.8), and delete moves off
  double-click to ⌥-click — a stuttered drag reads as a double-click and
  would destroy the beat. ⌥ starts no drag, so a slipped ⌥-drag abandons
  instead of deleting.
- ShortcutsPanel moves focus into the panel on open and returns it to the
  trigger on close; SpeedMenu's trigger is labelled and reports its popup.

Superseded by main, deliberately dropped: the seek-slider keyboard and
aria-valuenow fixes (the transport no longer owns a seek bar), the Player
load-error inline retry (main's reports the actual message and retries with
a cache-busting src), TimelineClip keyboard selection (main renders a native
button, and this PR's onKeyDown would have preventDefault'ed the synthesized
click), the keyframe-diamond keyboard guard and label (both already on main,
with a richer label), and the waveform's own cache/failure maps (main's
scheduler owns that). TimelineOverlays.tsx is a main-side file edited to
thread the two restored menu actions; BeatStrip.test.tsx tracks the new
gesture and hit target.

Restacked onto main now that PRs 1962-1966 have squash-merged, so this
carries only its own changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:02:47 -07:00