mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
6b360f56f74bb3cf643a0077a8554eba95f37469
1211
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6b360f56f7 | chore: release v0.8.25 (#3595) | ||
|
|
6b5b4cb988 | feat(studio): make agent edits live and explicit (#3581) | ||
|
|
aceaaebd68 | chore: release v0.8.24 (#3593) | ||
|
|
6cbe3fbe90 | chore: release v0.8.23 (#3586) | ||
|
|
38e356fba4 |
chore: release v0.8.22 (#3575)
* chore: release v0.8.22 * docs: include encoder retry in v0.8.22 notes --------- Co-authored-by: James <james.russo@heygen.com> |
||
|
|
f3099dcb27 | chore: release v0.8.21 (#3570) | ||
|
|
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>
|
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
724796e2f0 | chore: release v0.8.20 (#3555) | ||
|
|
28be8dddfa | fix(studio): correct save failure telemetry (#3499) | ||
|
|
0fd70b1d21 | chore: release v0.8.19 (#3551) | ||
|
|
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 |
||
|
|
5cc2f1bef5 | chore: release v0.8.18 | ||
|
|
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. |
||
|
|
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> |
||
|
|
f6de05efec | chore: release v0.8.17 | ||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
720ff5ac9c | chore: release v0.8.16 | ||
|
|
4f00336c92 | feat(player): add retained runtime data channels (#3471) | ||
|
|
9aaa7552fc | fix(studio): dedupe repeated selection telemetry (#3498) | ||
|
|
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 |
||
|
|
740f7ead89 | chore: release v0.8.15 | ||
|
|
acc6898255 | fix(core): address review — gate early diagnostic, fix empty crossOrigin, document gaps | ||
|
|
81069fe47f | chore: release v0.8.14 (#3474) | ||
|
|
3ed971d018 | chore: release v0.8.13 | ||
|
|
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 |
||
|
|
2ca578f945 | chore: release v0.8.12 (#3457) | ||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
32d58a73e3 | chore: release v0.8.11 (#3440) | ||
|
|
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. |
||
|
|
59a69a145b | chore: release v0.8.10 (#3426) | ||
|
|
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'`. |
||
|
|
f6e8e8ddfd | chore: release v0.8.9 (#3422) | ||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
6f82acf50c | chore: release v0.8.8 (#3411) | ||
|
|
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. |
||
|
|
5842dd8df4 |
fix(studio): invalidate the preview signature off the watcher that sees project writes (#3364)
* fix(studio): invalidate the preview signature off the watcher that sees project writes The preview ETag is a hash of the project's files, memoised per project directory. That cache was cleared from Vite's own watcher, which `server.watch.ignored` deliberately excludes `data/projects/**` from, so nothing ever cleared it: the ETag stayed frozen for the life of the dev server, the preview answered every revalidation with 304, and the browser went on serving the composition as it was when it first loaded. The visible cost is thumbnails. Their disk cache key already content-hashes the composition, so an edit correctly asks for a fresh capture, but the capture is taken against the stale page, and a clip's filmstrip keeps showing frames of a layout that no longer exists until the dev server is restarted. Studio already runs its own chokidar watcher over exactly these directories, because Vite's would answer a composition edit with a full page reload. That watcher now owns the invalidation, and the cache asks it to follow any project directory it has not seen. All five event types count: an added or deleted asset changes the signature as surely as an edited one. The cache moves behind `createProjectSignatureCache` so the invalidation rule is a unit under test rather than a subscription buried in the adapter. * fix(studio): filter signature invalidation, and stop the CLI server missing motion saves Review follow-up on the unfiltered invalidation. The watcher fired on everything under a project dir, but the signature walk skips 14 directories and `.thumbnails` is one of them. That directory is where the thumbnail route keeps its disk cache, and every capture also reads the preview, so populating a timeline row discarded the memo on roughly every request of the one workload it exists for. The filter is a single exported predicate beside the exclusion set it reads, and it is applied inside `invalidate` rather than at the watcher, so no caller can subscribe and forget it. It is deliberately not `WATCHER_EXCLUDED_DIRS`: that set is character-identical but drops all of `.hyperframes/`, and the signature reads two manifest files back out of there. Which is the same bug, still live, in the CLI server: its watcher filters through `shouldWatchProjectFile`, so `.hyperframes/studio-motion.json` never reached the listener that clears the cached signature. Studio writes that file at runtime, so saving motion state left the preview ETag stale until restart. The watcher now admits signature-relevant paths and the reload listener re-applies its own filter, so what triggers a browser reload is unchanged. Also from review: drop the `createViteAdapter` signature-cache default, which produced exactly the memo-nothing-clears bug this PR fixes, and correct the docstring — the content hash is already gated behind a stat fingerprint, so what the memo saves is the walk. |