* 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
* 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
* 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
`muxVideoWithAudio` passed `-avoid_negative_ts make_zero` unless the caller
set `preserveAudioPrimingEditList`. In practice the dominant path is an AAC
sidecar copied into mp4, where that flag is actively harmful: ffmpeg's
default is `auto`, which the mp4/mov muxers (AVFMT_TS_NEGATIVE) already
resolve to `disabled`. Forcing `make_zero` overrides the correct default,
discards the priming edit list the sidecar encode created, shifts the video
start_time forward by one AAC frame and writes an empty video edit at t=0 —
which edit-list-honoring players (QuickTime/Safari) render as a black first
frame.
Verified with ffprobe on a copy mux of a 30fps h264 mp4 and an AAC sidecar:
with `make_zero` video start_time 0.066000, elst: [media time -1,
dur 5940] + [media time 6000, dur 180000]
audio start_time 0.042993, elst: [media time -1, ...]
without (this fix) video start_time 0.000000, elst: [media time 6000,
dur 180000]
audio start_time 0.000000, elst: [media time 1024, ...]
The empty leading edit and the offset both disappear, and the audio keeps
its 1024-sample priming edit.
The flag is now never passed for a mux, in any mode. `preserveAudioPrimingEditList`
is part of the exported engine API, so it stays on `MuxVideoWithAudioOptions`
as `@deprecated` and no-op rather than being removed; the two internal callers
that set it (`assembleStage`, distributed `assemble`) drop it.
`buildEncoderArgs` and `streamingEncoder` still pass the flag for video-only
output and are deliberately left alone — those chunks are consumed as
intermediates, not as a delivered mp4/mov.
Fixes#3487
Co-authored-by: Alexandru Mincu <alex@mountsoftware.ro>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): keep phrase-level CJK and Thai transcripts as separate cues
wordsToCues inferred whether entries were already grouped into phrases
by testing for internal whitespace. Chinese, Japanese, Thai and the
other scripts written without inter-word spaces never satisfy that
test, so their phrase-level transcripts were treated as word-level and
re-grouped into a single cue covering the whole clip.
A three-phrase Chinese transcript produced one cue; the same transcript
in English produced three. The failure was silent: the export
succeeded, and the user found out by watching the captions.
For entries with no whitespace at all, fall back to entry length when
they are in a spaceless script. Whisper emits word-level tokens for
those scripts one or two characters at a time, while a phrase-level cue
runs to several times that. The median is used so one long token cannot
declare word-level input pre-grouped, and a couple of short cues cannot
declare a real transcript word-level.
--preserve-cues still forces the same thing, and behaviour for
space-separated scripts is unchanged.
Fixes#3353
* test(cli): pin the spaceless phrase length threshold
Parent src-only scans skipped multi-format <video>/<audio> markup, so those
elements were never extracted, downloaded, or mixed and rendered blank/silent.
Lint now accepts a child <source src> as a resolvable media src.
lookupCacheEntry reported a hit purely on the presence of the
.hf-complete sentinel. The sentinel records that extraction finished,
not that the frames survived, so any per-file cleanup that empties the
directory leaves an entry that rehydrates with zero frames.
rehydrateCacheEntry then returns totalFrames: 0, the clip reaches the
coverage gate with nothing, and the render aborts with a message about
capture coverage. Because the poison is on disk rather than in the
composition, every later render of the project fails the same way with
nothing the user can change to fix it.
An entry now counts as a hit only when it carries the sentinel AND
still holds at least one frame file, so an emptied entry re-extracts.
The check is format-agnostic: a hit must be usable whatever extension
the frames carry.
Addresses the cache half of #3372.
compileTimingAttrs/injectDurations used parseFloat, so data-start="intro"
wrote a NaN data-end and extract preferred that over duration; parseNumeric
now skips the id-ref (parseVideoElements already resolves it).
collectRenderMedia's resolveHostWindow likewise read host data-start with
parseFloat, so chained sub-composition slots (data-start="hook") stacked at
0-2s and every scene after the first rendered black. It now resolves host
starts through the shared resolveReferencedStart, matching the media parsers.
Fixes#3361.
* fix(producer): fall back to screenshot capture on drawElement canvas-not-initialized
The fast-capture drawElement path only special-cased the "No cached
paint record" error to trigger a per-frame screenshot fallback; every
other error (including "drawElement canvas not initialized", seen at
frame 0 on some macOS/Chrome combinations) was rethrown, hard-failing
the whole render even though the docs promise automatic fallback on
incompatible compositions.
Extend the existing fallback branch (in both captureFrameCore and
captureFrameToBufferPipelined) to also catch canvas-not-initialized
errors via a shared isRecoverableDrawElementError predicate, with a
diagnostic message identifying which case triggered the fallback.
Closes#3423
Co-Authored-By: Miga <noreply@anthropic.com>
* fix(producer): address review — tighten error matching, audit batch path, add fallback-ratio guard
* fix(engine): add prepareFrameForCapture to batch screenshot fallback loop
* fix(engine): split canvas-not-initialized from composition-root-missing errors
drawElementService threw the same HF_DE_CANVAS_NOT_INITIALIZED error for
both !canvas and !root. Missing composition root (navigated/broken page)
was classified recoverable and fell back to pageScreenshotCapture, which
captured blank or wrong content silently.
Now:
- !root → HF_DE_COMPOSITION_ROOT_MISSING (not recoverable, hard fail)
- !canvas → HF_DE_CANVAS_NOT_INITIALIZED (recoverable, screenshot fallback)
Split applied at all 3 emit sites (serial, pipelined, batch).
Co-Authored-By: miga-heygen <miguel.sierra_miga@heygen.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Miga <noreply@anthropic.com>
Root cause: the per-worker capture calls in captureFrameRange
(parallelCoordinator.ts) take no abort signal of their own, and only
checked `signal.aborted` BEFORE starting each frame — a no-op once a
worker is already awaiting an in-flight call. On WSL2, the native
drawElement/BeginFrame capture call can hang indefinitely at frame 0
with no error. The DE parallel-router's existing stall watchdog
(captureStreamingStage.ts) correctly fires `stallController.abort()`
after HF_DE_STALL_MS, but that abort had no way to reach a
worker already wedged inside a hung capture call — so
executeParallelCapture's Promise.all waited forever, the render hung
indefinitely, and the CLI's circuit breaker (which only runs after
executeRenderJob settles) never got a chance to trip.
Fix: race each per-frame capture call against the signal actually
firing (raceAgainstAbort), the same "can't cancel, only race" pattern
already used by the sequential capture path. Once the watchdog's abort
is observed, the wedged worker rejects, executeParallelCapture settles,
and the existing pinned-fallback retry / "reverted" outcome / circuit
breaker machinery (already correct) runs end to end.
Also widen the CLI breaker's trip condition from the literal string
"reverted" to "not a clean routed success", so any future non-success
outcome the observability layer records also latches the breaker
instead of silently falling through.
Closes#3441
Co-authored-by: Miga <noreply@anthropic.com>
Classify each <audio> element before Web Audio capture: same-origin,
CORS-opted-in, or a non-http(s) scheme stays on the primary
createMediaElementSource() path; cross-origin media without a
crossorigin opt-in withholds that call (the Web Audio spec makes such a
node output silence without throwing) and falls back to fetch +
decodeAudioData, preserving the FX graph whenever the server allows
CORS. Recheck the route at the transport's irreversible capture
boundary, and account for currentSrc, src, and <source> candidates the
same way the HTML resource-selection algorithm does.
Emit a stable preview diagnostic (`runtime_web_audio_bypass`) at media
discovery time, not only from playback scheduling, so `hyperframes
check` — which seeks but never plays — can surface it as a
`web_audio_bypass` finding. Diagnostics are suppressed during export
rendering, where the producer mixes audio offline and already applies
the FX chain. The existing non-unit-rate fail-closed rule stays scoped
to fx-chain/automation so this fix does not newly mute grouped or
above-unity tracks.
Takes over #3459 with the data-native-audio escape hatch removed per
review feedback: the automatic cross-origin detection already covers
the cases that mattered, so the extra per-element opt-in attribute,
its route-classifier branch, and its diagnostic path are dropped in
favor of a single automatic behavior.
Fixes#3458
Original-Author: desenmeng
Co-Authored-By: desenmeng <desenmeng@users.noreply.github.com>
Co-Authored-By: Miga <noreply@anthropic.com>
`registry_item_added` fires when a catalog block is installed and
`render_complete` fires when a video is produced, but nothing joined them, so
"did this video use the catalog?" had no answer.
`hyperframes add` now records each installed item in `hyperframes.json`
(installed files are plain composition HTML with no provenance marker, so this
manifest is the only record that a file came from the registry), and
`render_complete` reports both the items the project installed and the blocks
the rendered composition actually reaches. An item installed and then never
mounted was tried and dropped, which no add-time event can express.
The scan answering "which sub-compositions does this file mount" now has one
owner, `collectSubCompositionSrcs` in `@hyperframes/parsers`, shared with
lint's `lintMissingOrEmptySubComposition`. It holds two invariants that were
previously restated per call site and got re-derived wrongly: it is a text scan
rather than a DOM query, because `<template>` content is inert and every
sub-composition except the render entry is wrapped in one; and references
resolve root-relative at every nesting level, matching `parseSubCompositions`.
It walks tag by tag rather than running open-ended spans across the whole file,
so a malformed composition cannot stall the render plan.
Also: `registryItems` is declared in the config schema, which closes with
`additionalProperties: false`, with an ajv-backed test pinning every key the CLI
writes; counts are never truncated by the name cap, and the reported used blocks
stay a subset of the reported installed ones, with `registry_items_truncated`
marking a windowed list; and an unreadable manifest reports itself rather than
posing as a project that never used the catalog.
The runtime absorbed a series of authoring mistakes over time and `runtime/init.ts`
says so in its own comments, but the skills kept teaching the old rules. Four of
them actively cost an agent a failing run: add `crossorigin` (lint rejects it
unconditionally), never build a timeline inside `async` (lint calls that the
documented contract), never `gsap.set` later-scene clips (two fixHints instruct
exactly that), and 12 copyable media snippets with no `id`, which render silent.
Corrected in every place each claim appeared, including `hyperframes-animation`,
three workflow scripts, the scaffolded project instructions, the CLI `docs`
command, and the public docs site: `data-track-index` is a Studio display lane
the render never reads, `class="clip"` is a layout convention rather than a
visibility requirement, timed elements may nest, the visibility window is
half-open, sub-composition host dimensions are backfilled, and the root-fill rule
applies only to the layered-composite path.
Behaviour changes, each backed by a render rather than by reading code:
- `timeline_registry_missing_init` deleted. The runtime creates the registry
before any inline script; a composition without the guard line renders and
animates correctly.
- `video_nested_in_timed_element` kept, message corrected. A rendered repro shows
the nested-with-local-start case really does break, so the rule guards a real
defect, but nothing is "FROZEN": the extractor ignores the wrapper's offset
while visibility uses it, so the clip shows wrong frames and then vanishes.
- `mediaRenderIds` now stamps media whose source is a `<source>` child, closing a
duplicate-id gap the old `[src]`-only selector left open.
- Stale messages fixed on `subcomposition_root_styled_by_class` and
`deprecated_data_layer`.
`coreSkillContent.test.ts` pinned the literal sentence that made root
`data-start` look required, so it is narrowed to structure plus the regression it
genuinely catches.
Not covered, and flagged in the PR: the media global-vs-local start heuristic in
`runtime/init.ts` is the root cause behind the nested-video defect. Removing it
changes the meaning of existing compositions and needs its own deprecation.
* 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
* 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
* fix(studio): reconnect property-panel audio controls
* fix(studio): unify property panel audio detection
* fix(studio): satisfy panel and deletion gates
* feat(studio,core)!: remove solo and the group meter
* docs(audio): keep removal rationale current
* refactor(core): retire studio solo bridge
* docs(audio): document grouped audio and its guardrails
* docs(audio): point handoff at replacement stack
* 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(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(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(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(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
* 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