3063 Commits
Author SHA1 Message Date
Vance Ingalls 61ba800a5d fix(player/runtime): rebind timelines and bound paused seeks (#3489)
* fix(player): rebind replaced direct timelines

* fix(runtime): rebind timelines after runtime data

* fix(player): defer initial iframe navigation

* fix(player): preserve runtime readiness through load

* fix(runtime): publish rebound timeline before apply

* fix(player): defer preconnect option reloads

* fix(runtime): stop re-seeking paused timelines

* fix(player): restrict runtime-src to trusted origins and reset readiness on reload
2026-08-30 15:46:41 -07:00
Vance Ingalls 859ac622c2 fix(player): report and fail closed on runtime delivery errors (#3472)
* feat(player): report runtime data application

* fix(player): fail closed on runtime data delivery

* fix(player): close runtime delivery and sandbox gaps

* fix(player): satisfy runtime contract and CodeQL

* refactor(player): simplify runtime tag scanner

* style(player): apply repository formatter

* fix(player): type runtime tag boundaries

* fix(core): mint guest-local runtime-data ids in a separate space from host ids
2026-08-30 13:00:14 -07:00
3337cc8990 feat(studio): give an agent eyes with studio_frame (#3516)
* feat(studio): let an agent drive Studio's selection and playhead

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

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

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

Two things the tools refuse to fake:

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

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

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

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

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

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

Two things this does not fake:

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

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

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

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

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

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

Three things it refuses to get wrong:

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

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

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

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

---------

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

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

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

Two things the tools refuse to fake:

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

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

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

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

Fixes #3549
2026-08-29 13:54:27 -04:00
Miguel Ángel 5cc2f1bef5 chore: release v0.8.18 2026-08-29 15:38:26 +00:00
Miguel Ángel 7bd0782737 Merge pull request #3547 from rajanpanth/fix/studio-activate-comp-any-path
fix(studio): activate a composition at any path, not just compositions/
2026-08-29 11:32:53 -04:00
rajanpanth adf9b0ccee fix(studio): activate a composition at any path, not just compositions/
The Comps panel sets activeCompositionPath to the selected file, but
useCompositionStack's effect only pushed a stack level when that path
started with compositions/. A project laying its comps out anywhere
else, for example a generated multi-part build with parts/part-1.html
next to the root index.html, matched no branch at all: the row
highlighted and the URL hash updated while the stack silently kept the
master mounted, so the canvas and timeline stayed on index.html and any
edit landed in the root file instead of the part.

Replaced the prefix test with a plain truthiness check, so the root
stays on the master level and every other path pushes its own level.
Label derivation is unchanged, matching CompositionsTab's own
convention.
2026-08-29 20:50:16 +05:45
Miguel Ángel 3dc1856232 fix(core): resolve long nested media in local time (#3535)
* fix(core): resolve long nested media in local time

* test(core): cover nested media snapshot sweep
2026-08-29 02:48:47 +00:00
Miguel Ángel d99eeef0b2 fix(cli): honor authored playback rate in snapshots (#3536) 2026-08-29 02:48:33 +00:00
Miguel Ángel e4dabf830c fix(cli): prevent keyframe shots overwriting sources (#3534) 2026-08-29 02:43:37 +00:00
Miguel Ángel b28747df0f fix(cli): respect timeline-free static compositions (#3533) 2026-08-29 02:43:33 +00:00
miga-heygenandClaude Opus 4.6 da6514d458 fix(studio): update WebMCP test for polyfill fallback
The "registers nothing when the browser has no WebMCP" test asserted
that document.modelContext was absent after mount. Since #3514 added
the @mcp-b/global polyfill fallback, the hook now installs
document.modelContext even when the browser has no native support —
that is the polyfill's job. The real assertion is that mounting does
not throw, which still holds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-28 03:34:28 +00:00
Miguel Ángel f6de05efec chore: release v0.8.17 2026-08-28 00:35:58 +00:00
miga-heygenandMiguel Ángel 4d87f8bbae fix(producer): enforce video extraction failures by default (#3372) (#3526)
The extraction failure policy defaulted to "off", silently swallowing
per-source errors. The plumbing to surface them (typed error, retryable
classification, caller throw) was fully built but gated behind an
env-var opt-in. Flip the default to "enforce" so extraction failures
fail the render instead of producing misleading coverage aborts.

Set HF_VIDEO_EXTRACTION_FAILURE_MODE=off to restore the old behavior.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-08-28 00:31:37 +00:00
Val 9eb84c91b6 fix(core,producer): stamp render ids on empty-src media and pair the snapshot by them (#3513)
Residual of #3340: runtime-assigned src is skipped by the static parse, so
the browser snapshot was still keying clips by author id. Colliding scenes
collapsed onto one window.
2026-08-28 00:26:08 +00:00
miga-heygenandClaude Opus 4.6 e69be30e98 fix(engine): fail render on sub-composition script failures (#3352) (#3528)
When a composition script throws during execution, the GSAP timeline
registration never arrives and pollSubCompositionTimelines times out.
Previously the render continued with a degenerate 2-frame output and
reported success — now it fails loudly.

Two changes:
1. Detect composition script runtime errors in the browser console
   handler and feed them into scriptLoadFailures, triggering the
   existing fail-fast path (same as script load 404s).
2. Make sub_timeline_script_failure a fatal warning in
   applyRenderWarningPolicy, alongside audio_processing_failed.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-08-28 00:21:34 +00:00
miga-heygenandSanthi Prakash 05275c1e8c fix(producer): assert render artifact duration and frame count before commit (#3506)
* fix(producer): assert render artifact duration and frame count before commit

Refuse to publish an artifact that is significantly shorter or has fewer frames

than the capture pipeline just reported. Adds a duration/frame-count gate on top

of the existing readable-non-empty check inside ArtifactTransaction.validate(),

keyed off the values the orchestrator already carries. Closes #3395.

* fix(producer): wire ffprobe frame count into the artifact duration probe

The frame-count gate added in #3395 accepts an expectedFrames value from
the orchestrator, but defaultArtifactDurationProbe was still returning
only durationSeconds - so the wire was half-built and the assertion
short-circuited on undefined for every real render. Forward meta.frames
from ffprobe so the field-packet case the issue names (container duration
correct, stream shorter) is actually caught by the frame-count check,
not just the duration one.

extractMediaMetadata now populates a new frames field from the video
stream's nb_frames tag, returning undefined when the demuxer did not
report one (fragmented MP4, malformed streams, muxes that require
-count_packets). Callers that gate on the count must treat undefined as
no answer; the assertion already does.

The previous CI run (#32589981916) cancelled shard-6 at the 1h job
timeout after bun install failed to extract the aws-cdk-lib tarball
mid-Docker-build - a cache flake, not a code regression. Pushing a
follow-up commit retriggers CI against the now-populated cache layer;
the regression should clear without further code changes.

---------

Co-authored-by: Santhi Prakash <b.santhiprakash@gmail.com>
2026-08-28 00:19:27 +00:00
miga-heygenandMiguel Ángel e5c7dc75e8 fix(core): read CSS animation opacity through color grading's own hide (#3329) (#3507)
When a color-graded element has a CSS entrance animation (e.g. opacity:
0→1), the first drawEntry() copies the animation's initial opacity "0"
to sourceOpacityForCanvas, then hideSourceElement() sets opacity:0
!important on the source. On subsequent frames the hiddenByColorGrading
guard correctly prevents reading back grading's own hide — but also
prevents updating the canvas opacity as the animation progresses,
freezing both source and canvas at opacity 0 for the entire render.

Fix: when the source is hidden by color grading, temporarily restore the
authored inline opacity before reading getComputedStyle, so the CSS
animation's current value shows through. The restore–read–rehide is
synchronous, so no repaint occurs between the style writes.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-08-28 00:17:23 +00:00
Miguel Ángel 097d901d70 feat(studio): fall back to a WebMCP polyfill where the browser has none (#3514)
* feat(studio): fall back to a WebMCP polyfill where the browser has none

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

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

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

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

Two things the type checker forced, both worth keeping:

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

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

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

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

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

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

Three decisions worth knowing:

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

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

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

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

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

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

* fix(studio): bound WebMCP look filters

* fix(studio): remove premature WebMCP write state

* docs(studio): name WebMCP singleton assumption

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(studio): honor DOM edit failure outcomes

* fix(studio): classify stale delete previews

* fix(studio): enforce DOM edit outcome types
2026-08-26 23:42:57 -04:00
Miguel Ángel 720ff5ac9c chore: release v0.8.16 2026-08-27 01:32:37 +00:00
ee64c3b116 fix(engine): stop destroying the AAC priming edit list when muxing (#3505)
`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>
2026-08-26 20:38:11 +00:00
Rajan Pantha 18409c9f27 fix(cli): keep phrase-level CJK and Thai transcripts as separate cues (#3436)
* 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
2026-08-26 20:17:25 +00:00
Val 97991bbd35 fix(engine,producer,lint): resolve <source> children for media extract and localize (#3238)
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.
2026-08-26 20:17:12 +00:00
Miguel Ángel c9f43ebcfb fix(engine): preserve source frame identity above 99,999 (#3503)
* fix(engine): preserve extracted frame identity

* fix(producer): order legacy distributed frames numerically
2026-08-26 12:30:49 -04:00
Vance Ingalls 4f00336c92 feat(player): add retained runtime data channels (#3471) 2026-08-26 07:48:00 +00:00
James Russo 9aaa7552fc fix(studio): dedupe repeated selection telemetry (#3498) 2026-08-25 22:58:37 -07:00
Rajan Pantha dae5b7b90b fix(engine): treat a sentineled cache entry with no frames as a miss (#3434)
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.
2026-08-26 05:36:32 +00:00
Val f52ec1c25f fix(producer,core): honor relative data-start id-refs in render media scheduling (#3252)
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.
2026-08-26 05:36:24 +00:00
Miguel Ángel 0c9d234bd8 Merge pull request #3481 from heygen-com/fix/web-audio-cross-origin-silence-v2
fix(core): prevent cross-origin Web Audio capture from silencing audio
2026-08-25 23:41:58 -04:00
a7e8674758 fix(producer): fall back to screenshot capture on drawElement canvas-not-initialized (#3480)
* 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>
2026-08-26 03:31:01 +00:00
Miguel Ángel 740f7ead89 chore: release v0.8.15 2026-08-26 03:23:41 +00:00
Miguel Ángel 7c40efbc62 fix(fonts): harden localizer release diagnostics 2026-08-26 03:02:24 +00:00
Miguel Ángel d6de083411 feat(cli): stamp font compiler version in localized HTML 2026-08-26 01:11:38 +00:00
Miguel Ángel ec68d40cc6 fix(cli): keep font localizer process ownership explicit 2026-08-26 01:11:38 +00:00
Miguel Ángel 38f8f9250a feat(cli): expose deterministic font localization 2026-08-26 01:11:38 +00:00
Miguel Ángel 744146eb6e fix(fonts): cover rendered case variants in subsets 2026-08-26 01:11:38 +00:00
Miguel Ángel f7fc0017a6 fix(core): evict cached MediaElementSource on src mutation, soften enforcement-point claims 2026-08-26 00:01:33 +00:00
miga-heygenandMiga 3202f3fb87 fix(producer): trip DE parallel-router circuit breaker on stalls and hangs (#3479)
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>
2026-08-25 23:54:49 +00:00
Miguel Ángel 32ef37cfc3 fix(core): correct web-audio-route subpath export resolution 2026-08-25 23:00:34 +00:00
James 3cacac5bdc fix(lint): decode JSON attribute entities 2026-08-25 07:27:00 +00:00
James 71e8e92ae9 fix: document safe template default editing 2026-08-25 06:54:02 +00:00
James 1e11608d44 fix: expose promoted template media slots 2026-08-25 05:33:50 +00:00
Miguel Ángel acc6898255 fix(core): address review — gate early diagnostic, fix empty crossOrigin, document gaps 2026-08-25 05:25:24 +00:00
cce17da5a9 fix(core): prevent cross-origin Web Audio capture from silencing audio
Classify each <audio> element before Web Audio capture: same-origin,
CORS-opted-in, or a non-http(s) scheme stays on the primary
createMediaElementSource() path; cross-origin media without a
crossorigin opt-in withholds that call (the Web Audio spec makes such a
node output silence without throwing) and falls back to fetch +
decodeAudioData, preserving the FX graph whenever the server allows
CORS. Recheck the route at the transport's irreversible capture
boundary, and account for currentSrc, src, and <source> candidates the
same way the HTML resource-selection algorithm does.

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

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

Fixes #3458

Original-Author: desenmeng
Co-Authored-By: desenmeng <desenmeng@users.noreply.github.com>
Co-Authored-By: Miga <noreply@anthropic.com>
2026-08-25 04:39:51 +00:00