1050 Commits

Author SHA1 Message Date
Miguel Ángel 10e8447ac0 chore: release v0.8.38 (#3929) 2026-09-13 22:02:19 -04:00
Miguel Angel Simon Sierra 7a5d2bf769 chore: release v0.8.37 2026-09-13 12:51:02 -04:00
Miguel Ángel cd975d8c28 fix(core/bundler): inline fonts and images so a lone bundle renders (#3590)
`bundleToSingleHtml` documented itself as producing "a single
self-contained HTML file", but `INLINE_MIME` covered only `.svg`,
`.json`, `.txt`, `.cube` and `.xml`. Every font and raster image stayed
a live project-relative reference.

That is invisible to every consumer in this repo, because each one
serves the bundled string from a server rooted at the project directory,
so the relative paths resolve. It breaks the moment the bundle is stored
on its own, with no sibling asset directory: the font 404s and the page
silently reflows in a fallback face, which is worse than a visible
failure.

Widen the inline set to fonts (woff2/woff/ttf/otf) and raster images
(png/jpg/jpeg/gif/webp/avif), behind a 2 MiB per-asset cap. The cap is
measured against this repo's own assets rather than guessed: the largest
of 164 tracked `.woff2` files is 105 KB, and the largest of 284 tracked
raster images is 2.00 MB, so everything in-tree inlines while a
video-sized file cannot. Oversized assets keep their relative URL and
warn, reusing the existing "may not be self-contained" wording.

Audio and video stay external on purpose: they are large, streamed
rather than laid out, and their absence is obvious rather than silent.
Scripts already had a better path (`script[src]` is folded in as source),
so they are deliberately not added to the MIME table.

The five rebasing tests that asserted a relative path survived now
assert the data URL's decoded content instead. That is a stronger check:
resolving from the wrong base directory finds no file, so nothing
inlines and the assertion fails.
2026-09-13 12:47:01 -04:00
miga-heygen 1acc65dbf8 fix(core): un-hide a later root-level clip instead of leaving it display:none forever (#3893)
A root-level `[data-start]` clip with no authored `position` starts out
`position: static` until the runtime forces it to `position: absolute`. A
visibility pass over the clip while it's still inactive can observe the
pre-forcing `static` value and cache it as in-flow, which correctly hides
the clip with `display: none`. But by the time the clip later becomes
active, that same cache has been invalidated and recomputed against the
now-forced `absolute` position — so the un-hide check, which re-derived the
same cached fact instead of tracking what it had actually done, saw
"not in-flow" and skipped the removal. The clip stayed `display: none` (and
therefore zero-sized) for the rest of the render.

Tracks whether this code applied `display: none` to a given element in its
own WeakSet instead of re-deriving the in-flow cache, so the un-hide check
can no longer disagree with the hide check that set it.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-13 03:58:12 +00:00
Miguel Ángel f86aae655a chore: release v0.8.36 (#3911) 2026-09-12 13:03:01 -04:00
Miguel Ángel 4c6fa9a790 fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref

`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.

Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.

Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.

* docs(studio): plan for loading the preview during the shell's first layout

* fix(runtime): a paused preview stays still after any seek

Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.

Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.

- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
  now returns every timeline it unpaused to paused in a finally. The second
  rearm after the child re-seek only ever changed the leaked state and is
  gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
  timed media element found running. A parked transport runs no ticks, so a
  capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
  requests playback through the player so adapter, rAF loop and flag agree.

Regression tests fail without each of the three changes.

* perf(studio-server): stop re-encoding videos the browser already plays on open

Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.

One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.

Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.

Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.

* perf(cli): serve the studio bundle compressed and cache hashed assets

The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.

The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.

* perf(cli): warm the preview route before the browser opens

The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.

The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.

* perf(studio): request the player chunk before the shell's first layout

The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.

* fix(studio): editing a text property no longer reloads the preview

Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.

The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.

A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.

shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.

Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.

* fix(runtime): paused-time media playback is borrowed, not banned

The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.

- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
  video while paused to render grading previews. It went dark on the first
  paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
  music track for ~140 ms while paused so a playhead drag is audible. Same path
  killed it.

A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.

Two corrections to the previous commit's reasoning:

The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.

Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.

Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing

* test(runtime): a lease ends when the transport plays or the borrower stops

Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.

* test(studio): the event-stream channel must open /api/events

* fix(studio-server): stop printing a proxy diagnostic line per clip on every render

Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.

The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.

The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.

The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".

Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.

* perf(cli): stop gzipping the studio bundle on loopback

Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.

The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.

The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.

* docs(runtime): say what the paused-media probe actually filters

* revert(cli): drop the preview prewarm that held the CLI event loop

The fire-and-forget warm added in c9b4263c7 keeps the CLI alive until it
settles. cli.ts drains the event loop instead of calling process.exit, and
two launch paths return straight into exit, so the warm delays them: 2660 ms
with the warm suppressed, 8779 ms against a server answering in 6 s, 13293 ms
against one that hangs. The 10 s abort bounds the hold, it does not remove it.
This is the hazard already documented at preview.ts:1605-1609.

Moving the warm into the server process was the obvious fix, but measurement
says there is nothing there to warm. tsup bundles @hyperframes/core/compiler
into cli.js -- bundleToSingleHtml is a plain inline function in the artifact --
so the await import() at studioServer.ts:397 resolves an already-evaluated
namespace. A probe at listen time measures that import at 1 ms, and a paired
run shows no gain: first request 256 ms without a compiler kick, 308 ms with
one. The cold cost the plan attributed to the module load is somewhere else.

Two claims in the reverted comment were also wrong: routes/preview.ts:332 is
an ETag, not a bundle cache, and the player appends no shader params -- the
only query param is variables, which IS part of the ETag.

Reverts packages/cli to origin/main exactly.

* fix(studio): let a remount retry the player chunk after a failed load

1037456aa hoisted the dynamic import to module scope and shared one promise
across every mount. On rejection the .then never ran again: retryPreviewRef
and previewError stayed null, compositionLoading stayed true, so the preview
sat on an infinite spinner with the retry button at Player.tsx:462 unreachable,
and remounting <Player key={activeKey}> -- the recovery path that used to work
-- awaited the same rejected promise.

Memoize through a getter that clears the memo on rejection instead. The
module-scope kick still fires the chunk request before the shell's first
layout, and a remount performs a fresh import as it did before the hoist.

Discloses what the earlier commit did not: the player barrel re-exports
Player, so every studio module importing that barrel now evaluates this one
and eagerly loads the real player bundle. That is 27 DOM-env test files;
the full studio suite passes, 439 files and 4838 tests.

* docs(studio-server): trim the proxy-counter comment to the repo's four-line cap

* fix(studio): stop one failed player import poisoning every later mount

The previous commit claimed a remount could retry the player chunk after a
failed load. In a browser it cannot: the module map caches a failed fetch
as an errored entry for the document's lifetime, so a repeated import of
the same specifier rejects from cache without touching the network
(measured in headless Chrome: three attempts, one request). Clearing the
memo on rejection is still right, because it stops every later mount
awaiting the same poisoned promise, but recovery is a page reload, and the
comment now says so.

The tests said what vitest does, not what the browser does. Two assertions
survived their own mutation: removing the memoisation left the attempt
counter unchanged because vitest caches a resolved mock, and deleting the
module-scope kick, the optimisation this branch exists for, left the suite
green. Both are now asserted directly: the kick has run once at import
time before any mount, and every mount receives the identical promise.

* chore(ci): allow the deletion of the dead sdk-session reload test

* fix(runtime): the timeline resolver no longer unpauses children it cannot drive

Exercising the branch in a real browser still showed sub-compositions
free-running while paused, on a path no seek follows: the timeline
resolver, which runs on every rebind (after an edit, on the periodic bind),
unpaused every registered child BEFORE trying to nest it into the root.
A child the root actually holds is driven by the paused root and is
harmless; a child the root never takes stays on GSAP's global ticker, and
unpaused there means running. The seek fix in this branch could not
reach it, because a rebind is not a seek.

The resolver now reads back which children the root holds and unpauses
only those; the same rule applies to the composite fallback timelines.
Standalone registry children stay paused, as the transport's per-child
seek already requires.

Regression test: a hosted registry child whose root cannot nest it stays
paused across a forced rebind. Fails on the previous resolver.

* test(runtime): pin that the resolver unpauses a child only once the root holds it

The resolver's positive half had no fixture: nothing anywhere nested a
candidate for real, so computing the held set before the add loop instead
of after it left the suite green. A root that reports children only once
added now distinguishes the two orders, and the unpause is asserted at
the moment it happens, because the transport's first seek pauses every
hosted child again straight after resolution. Also drops a cast that
re-declared getChildren, which RuntimeTimelineLike already has.

* fix(runtime): the paused side stops every clip the transport drives

The paused-side enforcement scanned only media carrying its own data-start.
A clip inside a composition inherits its timing from the host and has no
data-start of its own, yet the transport plays it, so it could start while
paused and run unopposed, exactly the hole the invariant was written to
close. Reviewer probe: a hosted video with data-duration only kept
running while paused; the same element started under __player.play().

"Media the transport drives" now has one definition, shared by the media
cache and the paused-side probe, so neither can be narrower than the
other. Regression test: hosted media with no data-start started while
paused is stopped. Fails against the data-start-only scan.
2026-09-12 12:56:00 -04:00
Vance Ingalls 4ba8396c27 chore: release v0.8.35
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:30:23 -07:00
Vance Ingalls e488871ce1 fix(audio): pin the slower release and the carve default, fold the requirement, sync the manifest
Review at fe53f51a: the moved test points passed at both 1.6s and 2.4s, so a
revert of DUCK_RELEASE_S was silent. Add the 9s probe that reds on 1.6s. Export
DEFAULT_STRENGTH and parseArgs from carve.mjs and pin 0.8 in carve.test.mjs.
Fold the new requirement paragraph into the existing 'Carve by default' one so
the rule is stated once. Regenerate skills-manifest.json after the last edit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 13:57:07 -07:00
Vance Ingalls fe53f51adb fix(core): slow the voiceover carve's level release to 2.4s
At 1.6s the bed audibly came back at every sentence break on narrated builds,
which reads as the effect switching off rather than the mix breathing. 2.4s
lets it swell back over a breath. Band-filter release is unchanged; a notch
closing is inaudible. Test timings move with the constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 11:27:37 -07:00
Miguel Ángel 59def1b66d chore(release): v0.8.34 (#3854) 2026-09-10 14:45:17 -04:00
renovate[bot] eae4892ae8 chore(deps): update dependency vitest to v4 [security] (#3789)
* chore(deps): update dependency vitest to v4 [security]

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

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: James <james.russo@heygen.com>
2026-09-10 13:55:52 -04:00
Miguel Ángel c752b89bc9 fix(runtime): the editor keeps waking during a drag on a preview it did not build (#3850)
The manual-edit gesture watch tested mutation targets with `instanceof
Element`. The composition body is adopted into the preview frame, so its
nodes answer to another realm's Element and the check is false for every
one of them: the watch never sees a gesture, and the paused transport it
gates does not wake while the user drags.

Routes the check through the runtime's structural predicate, which is
what the preview-guard lint added alongside it now requires. Main is red
on that lint for this line, so this also unbreaks it.

Test adopts an element from a second realm and asserts the marker is
seen and cleared; it fails with the identity check restored.
2026-09-10 16:29:32 +00:00
Miguel Ángel d4fba55a4e perf(runtime): a paused preview stops burning CPU (#3845)
* perf(runtime): stop the preview transport ticking when the editor is paused

A paused, untouched preview asked the browser for a fresh frame sixty times a
second and re-read the page on each one. Nothing it looked at could change
without some observable event firing, so the loop now stands down and wakes on
that event instead, with a slow timer as the safety net.

Parked, the loop keeps two jobs the 60 Hz version did implicitly: the control
bridge's paused heartbeat, on the same interval as before, and a re-read of the
timeline registry, which is a plain object no observer and no event can report.

Everything else arrives by an event now: timing-attribute edits, mounted or
removed timed elements and media metadata through the composition-timing
observer that already existed; a manual-edit gesture starting or ending through
a new attribute-filtered observer, which also replaces a whole-document query
that ran on every paused frame; and playhead or play-state changes through the
forced state post every transport mutation already ends in.

Three periodic jobs (re-binding the root timeline, posting the clip manifest,
binding media-metadata listeners) used a frame counter as a proxy for "the
document may have changed". They now ask that question directly, because the
counter stops advancing while the loop is parked.

The render path is untouched: the loop never parks while an export render is
driving frames. That test is the pair of renderCaptureSeekStarted and the
producer's injected seek config, not the flag alone, because Studio's own
preview falls back to renderSeek for overhanging timelines.

Idle, paused, no input, on a 1689-element project: main-thread self time
128 -> 8.5 ms/s and animation-frame callbacks 282 -> 4.3 per second, both
measured with the runtime and editor changes in place.

* fix(runtime): keep the rebind policy the only owner of "may rebind now"

The parked-loop change let a composition-timing change OR its way past
shouldAttemptPeriodicTimelineBind, which removed the hold that keeps an async
rebind off the first two seconds of playback, and let the clip manifest post on
every frame of a composition that mutates the DOM every frame (measured: 30
posts in 30 frames against one). The change is now an input to that policy,
which still applies the hold, and the change-driven path is confined to the
paused path and rate-limited to the posts per second the frame counter already
produced. A change it defers stays pending, and a pending change keeps the loop
awake, so deferring can never drop it.

Two more holes from the same review:

The parked poll compared only the composition timing revision, so an adapter
duration floor that grew was never noticed. Adapters infer duration from live
animation objects that change with no DOM mutation and no media event, which
makes it the second input nothing can push; both are now in one witness.

Draining the gesture observer's records to answer within a task suppressed the
observer's own callback for them, so a reader could consume the notification
that un-parks the transport. Draining now notifies.

The watch reports whether it is observing at all, and the loop refuses to park
when it is not. That path is unreachable today because the colour-grading
runtime constructs a MutationObserver unconditionally during the same init; the
flag is the explicit statement of the invariant for the day that changes.

* fix(runtime): clear the pending-change latch after the post, not before

postTimeline walks author DOM and can throw. The tail scheduler runs in the
tick's finally either way, so clearing the latch first let it see nothing owed
and park with the change undelivered — and nothing would deliver it until some
unrelated change happened to wake the loop again. Clearing after the post means
a throw leaves the change owed, the loop stays awake, and the frame counter
retries it within twenty frames. Same rule the shared editor loop already
follows for its own re-arm.

Also rewords the note on draining the gesture observer's records: that is
hardening, not a fix for a live lost wake. isActive has exactly one caller
today, inside transportTick, and a tick is its own task, so the observer's
microtask has already run by then.

* fix(runtime): stop the parked heartbeat when the page has gone away

The parked transport holds a timer where the old loop held only an animation
frame, and a frame is discarded when a page or a test environment is torn down
while a timer is not. A test that initialises a runtime and abandons it
therefore left an 80ms timer to fire into a dead global, which CI reported as
an unhandled ReferenceError attributed to whichever file was running when it
landed.

Two changes, because the leak has two ends. The heartbeat stops instead of
re-arming when window or document is gone: nothing is left to report a state
change to, so stopping is the answer rather than throwing. And the one test
that initialised a runtime without ever tearing it down now tears it down.
2026-09-10 08:29:22 -07:00
Miguel Ángel 73dfe35e46 fix(preview): show the correct scene instead of every scene at once on some loads (#3848)
* fix(runtime): show the right scene when the preview DOM comes from another window

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two supporting changes:

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

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

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

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

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

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

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

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

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

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

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

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

No unrelated code was trimmed to make room.

Duplication, all three clone groups fallow flagged:

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

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

- offCanvasIndicatorRefresh.ts update was NEW (absent from main's report, 10
  cyclomatic / 31.6 CRAP here). The optional-chain-and-default I added to drain
  the observer becomes drainPendingLayerMutations, with its own unit tests, and
  the function drops off the report entirely.
- useDomEditOverlayRects.ts update: 37 cyclomatic / 69 cognitive on main AND
  here. My change added one statement and no branch; the function grew 139 -> 147
  lines, which is what resurfaced it. Left alone.
- domEditingDom.ts escapeCssIdentifier: 24 cyclomatic / 19 cognitive / 148.4
  CRAP on main AND here. Untouched by this branch; only its line number moved
  (173 -> 182) because the composition-source-map revision counter sits above it.
  Left alone.
2026-09-10 03:55:28 +00:00
Miguel Ángel db51d5479d fix(ci): generated files never read half-written, lint scaling test runner-proof, probe cache test off disk (#3834)
* fix(build): stop generated files from being read half-written during the build

Two unrelated pull requests kept failing CI for reasons that had nothing to do
with their changes.

Typecheck failed with "TS1002: Unterminated string literal" pointing at a
generated file. The root build compiles several packages at the same time, and
more than one of them regenerates files under a package's src/generated while a
sibling's tsc is already reading them. A plain write empties the file and then
refills it, so for a few milliseconds what is on disk is the first half of the
new file. Whichever build read it in that gap saw a truncated file and stopped.
It never happened locally because locally nothing else is reading.

Every generator now writes the new version under a temporary name and then
renames it over the target, which the filesystem does in one step. A reader
either gets the whole previous version or the whole new one; there is no moment
where it can see half of either. Content that has not changed is not rewritten
at all, so repeat builds no longer touch these files. Four generators were
affected and all four now go through one shared helper, which is where the rule
lives from now on.

The build also rebuilt the core package a second time, in parallel with the
packages that read it. That second rebuild was already redundant, and removing
it takes the writers out of the window entirely. Renaming is what makes the file
safe; dropping the duplicate build makes the build shorter as well.

Separately, a linter performance test failed on four of the last ten failed runs
on main. It timed a scan with a stopwatch and demanded the result come in under
two seconds; one run took 3.7s. That is a statement about how busy the shared
runner was, not about the code, because a stopwatch also counts the time the
machine spent running someone else's job. The test now counts only the processor
time this process actually used, and checks that the cost grows in step with the
input rather than against a fixed number of milliseconds. Measured on a machine
under load, the stopwatch ratio for the same input reached 45x while the
processor-time ratio stayed at 20x.

Both fixes come with a check that fails if the fix is removed.

* fix(build): keep the shared write helper inside the core package

The helper the generators call had been placed in the repo-root scripts folder.
Every container image that builds a package copies the packages folders whole
but cherry-picks root scripts one file at a time, so the image builds failed on
a missing module. The repo already shows both halves of that convention: the one
root script a package build imports has a matching copy line in the image, and
cross-package imports into the core package need none.

The helper now lives with the core package's other build scripts, and the one
generator outside that package reaches it the way the engine package already
reaches core.

* test(engine): keep the probe cache bound test off the filesystem

The eviction test wrote, stat'ed and deleted 129 temp files to exercise an
in-memory LRU rule. Its runtime tracked filesystem contention rather than
the code under test, and on a busy Windows runner the file churn alone
pushed a ~300ms test past the 5s timeout, failing unrelated pull requests.

Cache identity comes from stat, so the test now synthesises stat results
and never touches disk. While here, the test also asserts the LRU touch:
re-probing an entry before the bound is hit must keep it resident and
evict the next-oldest one instead. The previous shape never hit the cache
during the fill, so that branch was untested.

* test(lint): keep the scaling check inside the scanner's linear range

The CPU-ratio version sampled 320k characters, where the output string's
own growth dominates and the whole test cost seconds of CPU; on a shared
runner that tripped the default test timeout, the failure this change
exists to remove. Sample 10k and 80k characters instead, where 8x input
measures ~8x and a quadratic scan still measures 60x or more, and give
the test an explicit timeout so a slow runner reports the ratio.

* test(core): snapshot file identity through a descriptor

The before/after inode and mtime checks read the file through a path stat
and then exercised the writer on the same path, which reads as a
check-then-use race to static analysis. Snapshot through an open
descriptor instead; the assertions are unchanged.
2026-09-10 03:08:58 +00:00
Miguel Ángel 5973273f8d fix(runtime): resolve media clip start once for playback, manifest, and audio (#3833)
* fix(core): play and draw media clips at the same time

A clip placed inside a scene could be drawn on the editor's timeline at one
time and actually play at another. Nothing failed; the timeline just showed
the clip in the wrong place, and a clip pushed past the end of the composition
disappeared from it entirely.

The cause was that the code drawing the timeline and the code playing the
video each worked out the clip's start time from the same HTML attributes in
their own way, and only one of them knew about the marker that says "this
start time is already measured from the beginning of the whole video".

Both now ask the same function. The same function also answers for the
visibility pass, the audio scheduling paths, and the volume-fade probe, all of
which were reading the raw attribute and so placed a nested clip's audio at
the wrong moment.

Also folds the duplicated media-length helper into one shared version.

* refactor(core): resolve the volume probe window once per element

* fix(core): resolve media starts through the pass-scoped timing resolver

After rebasing onto the resolver-reuse change, the shared media start
resolver was building a fresh start-time resolver per call, which is the
per-element construction that change removed. Route it through the
scoped resolver so one pass shares one set of caches.
2026-09-10 02:30:16 +00:00
Miguel Ángel 3ea2f909e1 perf(runtime): reuse one timing resolver per pass and derive duration only when the composition changes (#3824)
* perf(runtime): reuse one timing resolver per synchronous pass

`createRuntimeStartTimeResolver` memoizes element start and duration
lookups in WeakMaps, but `resolveStartForElement` and
`resolveDurationForElement` each constructed a brand-new resolver per
call and discarded it. The caches never served a second lookup, so a
single pass re-walked the composition ancestry of every element, and
per-seek work scaled with total timeline content rather than with what
changed.

`withTimingResolver(fn)` installs one resolver for the duration of a
synchronous callback and restores the previous one in a `finally`, so a
throw cannot leak the scope. Callers outside a scope keep today's
construct-per-call behaviour unchanged.

Three separate scopes, deliberately not one:

- the media-cache build in `syncMediaForCurrentState`
- the body of `syncTimedElementVisibility`
- the media scan in `resolveMediaWindowDurationSeconds`

They must stay separate. `syncRuntimeMedia` calls `el.load()` on the
seek-past-buffered-range retry, which synchronously resets
`el.duration` to NaN, and `resolveDurationForElement` reads
`element.duration`. A cache spanning that write would serve the
pre-`load()` duration to a post-`load()` read. Splitting at the write
makes the staleness unrepresentable instead of merely handled.

The third scope sits on `resolveMediaWindowDurationSeconds` rather than
its caller `getSafeTimelineDurationSeconds`, which would look like the
tidier boundary: that caller also invokes author-supplied
`timeline.duration()` and third-party adapter
`getInferredDurationSeconds()`, and foreign code inside a cache scope
can mutate the DOM between two resolves.

Resolver constructions per seek plus transport tick go from 15 per media
element to a flat 4, and self time in `resolveStartForElementInternal`
falls about 70% on a 79-video composition.

* perf(runtime): derive composition duration only when the composition changes

The runtime re-derived the composition's total duration on every animation
frame. transportTick called getSafeTimelineDurationSeconds unconditionally,
and deriving it scans every media element in the document and walks each
one's composition ancestry to resolve an absolute start and duration.

That value is a function of the composition, not of the playhead, so a
paused editor with nothing happening recomputed the same answer ~60 times a
second. On a 91-media-element composition the media scan ran 1.05 times per
frame while the editor sat idle and untouched.

Derive it once and reuse it until an input could have changed. The inputs,
and the signal that catches each:

  - timing attributes edited (live editing, variables re-applied, the
    runtime's own autostamping) -> MutationObserver with an attribute filter
  - timed elements added or removed, including nested compositions that
    mount asynchronously after init -> the same observer, childList+subtree
  - media metadata arriving, or el.load() resetting duration to NaN, neither
    of which mutates the DOM -> capture-phase media event listeners
  - a timeline registered or lengthened in window.__timelines, a plain
    object nothing can observe -> a registry signature compared on read

Observer records are delivered in a microtask, so the queue is drained on
read as well; otherwise an edit read back in the same synchronous block, the
ordinary live-editing case, would be served the pre-edit value.

The render path does not read the cache at all. Capture depends on the exact
duration and a frame rendered against a wrong one cannot be recovered, so it
pays the full derivation on every frame exactly as before.

Measured on the 91-media-element composition, idle and untouched: media
scans per frame 1.05 -> 0.05. Across 100 single-frame seeks: 2.28 -> 0.11
per seek, so seeking does not invalidate the cache either. The residual is
a separate per-20-tick caller in timeline.ts, untouched here.
2026-09-09 18:53:15 -07:00
James Russo ff749b8705 fix(security): verify CDN script integrity before inlining (#3822)
* fix(security): verify CDN script integrity before inlining

* fix(producer): normalize SRI algorithm case

* fix(producer): abort compilation on integrity mismatch

* fix(compiler): preserve nested script integrity requirements

* fix(compiler): defer local inlining until integrity is known
2026-09-09 19:37:00 -04:00
James Russo c6010f2eb8 fix(registry): authenticate deck sound effect messages (#3819) 2026-09-09 14:22:41 -04:00
James Russo 05d7261052 fix: restrict runtime and playground message dispatch (#3816) 2026-09-09 13:56:58 -04:00
James Russo b8328f9573 fix(core): authenticate runtime control message senders (#3813) 2026-09-09 10:26:56 -04:00
James Russo 4233b5c6bf fix(core): contain generated HTML CSS and script contexts (#3800)
* fix(core): escape generator metadata attributes

* fix(parsers): retain decoded metadata while assigning ids

* fix(parsers): preserve runtime html parser semantics

* fix(parsers): canonicalize HTML attribute names for stable IDs

* fix(parsers): normalize SVG attribute hashes across HTML parsers

* fix(core): escape public resolution attribute values

* fix(core): contain generated HTML CSS and script contexts

* fix(core): preserve empty captions and document authored code trust
2026-09-09 03:56:10 -04:00
miga-heygen 3a7fcd10e0 feat(registry): add 25 image carousel blocks (#3790)
* feat(registry): add 25 image carousel blocks (5 families × 5 variants)

Five carousel families, each with 5 style variants:

- Orbit (1–5): image cards on a spinning 3D Fibonacci sphere
- Path (1–5): cards following animated CSS motion paths
- Circle (1–5): circular carousel layouts
- Vision (1–5): Apple Vision-style spatial presentations
- Text Circle (1–5): circular carousels with text overlays

All blocks are 1920×1080 at 6s, with 12–24 configurable image slots.
Includes catalog preview thumbnails for each block.

Co-Authored-By: Jake Moran <jake.moran@heygen.com>

* refactor(registry): host carousel block images on the CDN

The registry is served straight out of this repository
(DEFAULT_REGISTRY_URL points at raw.githubusercontent.com), so every byte a
block ships is permanent history. The 25 carousel blocks added 421 JPEGs,
27.7 MiB in a checkout, and made the diff 472 files. Only 23 of those images
were distinct: the same 12-24 placeholders were copied into every block.

files[] entries gain an optional `url`. When set, the installer fetches the
bytes from there instead of joining the registry base. `path` does not change
and still says where the file lands relative to the item, so composition HTML,
target mirroring and `hyperframes add` behave exactly as before.

Keys are content-addressed, so the 396 manifest entries resolve to 23 objects,
and a changed image gets a new URL rather than a stale one cached behind
`immutable, max-age=31536000`.

The catalog preview renderer copies an item's directory and renders it, so it
needs the same materialisation step. Without it the preview draws every card
blank and reports success, which is worse than failing.

Also drops registry/catalog/, 25 hand-made thumbnails referenced by nothing;
catalog previews are rendered by CI and served from docs/images/catalog.

Verified: all 23 objects return 200 from the CDN with hashes matching their
keys; `hyperframes add carousel-orbit-1` against a local registry installs 24
real JPEGs; the preview render produces the album art, and produces blank
cards when the fetch step is removed.

* style(registry): format the carousel composition HTML

`oxfmt --check .` covers the whole tree, and these 25 files were never run
through it. The pre-commit hook only formats staged files, so nothing local
caught it.

* feat(catalog): publish the carousel blocks without republishing their images

These 25 blocks had no Catalog page. Every other item in the registry has one,
so they shipped invisible: installable by name, unfindable by browsing.

Generating them naively undid the change they were added by. The Catalog
payload copies an item's assets into docs/public/, which is tracked, so the 396
images this PR just removed came back as 43 MB one directory over — worse than
the 3 MB they started as, because each block got its own copy.

The copy exists because these compositions assemble `img.src` at run time out
of a variable value, so there is no `src="..."` in the markup for the payload's
asset scan to resolve. An unpredictable path can only be satisfied by serving
every file beside it, which is what `needsOwnDirectory` asks for.

An absolute URL needs no directory: the scan already skips any `https:`
reference. So for the payload path only, hosted files are left undownloaded and
the composition's variable defaults are rewritten to their URLs. The preview
renderer still downloads them, because it paints real frames and a missing file
is a blank card.

The explorer posts every value to the preview frame on mount, including
untouched ones, so the page's variable list carries the URLs too. Left as local
paths they would have overridden the payload's own defaults and asked the frame
for a file that was deliberately never published.

Result: 25 pages, 25 payloads, zero bytes of image added.

Verified: a spike item declaring no assets at all rendered its 24 covers from
the CDN, proving the variable-default path; payload generation for a carousel
block now writes no item directory and no shared asset; the preview render
still produces the album art. mint validate and mint broken-links pass on the
new pages. test:scripts is green.

* refactor(catalog): split the hosted-asset step out of prepareProjectDir

Two functions rather than one: finding the composition and rewriting its
variable defaults are separate jobs, and inlining the mode branch pushed
prepareProjectDir past the complexity gate it was already sitting on.

Behaviour is unchanged. Re-verified both paths after the split: the payload for
a carousel block still writes no item directory and no shared asset, and the
preview render still produces the album art.

* feat(catalog): give the carousels their own shelf

25 image carousels landed in Showcases and were 53% of it, so the scenes that
shelf exists for disappeared underneath them. That is the same shape the 24
editor themes made, and it gets the same fix they got.

Keyed on the first tag, which is this file's stated grouping rule, rather than
on the name. `screen-flow-carousel` leads with `product-demo` and stays on the
shelf that says what it is for; a future carousel that is not named
`carousel-*` still lands here.

Showcases 47 -> 22, Carousels 25, and no existing item changed shelf.

* fix(registry): centre the circle-5 carousel path in its composition

Its ring was centred at x=3832.6 in a 3840-wide composition, so it sat on the
right edge and most of it fell outside the frame. Only a few cards were ever
visible, cropped, with two thirds of the composition empty.

The exported path carried absolute coordinates from a layout that was never
recentred. Shifting the four vertices by (-1912.597, -4.340) puts the ring on
the composition centre. Handles are relative, so only the anchor points move
and the shape is unchanged.

carousel-text-circle-5 shares the identical path and had the identical fault.

The other three circle variants sit within 12% of centre, which reads as
authored placement rather than the same bug, so they are left alone.

* fix(catalog): rebuild the circle-5 payloads after recentring the path

The Catalog preview plays the payload, not the composition on disk, so
recentring the source changed nothing a reader sees. The payload still carried
the old vertices and the ring still hung off the right edge of the frame.

Verified the consumer this time, not just the producer: both payloads now
resolve to a path centre of x=1920. The other 23 rebuild byte-identical, so the
formatting pass did not reach them.

* feat(catalog): promote Carousels to its own section

It was a shelf inside Scenes & demos, which is where a scene type belongs by
kind but not by weight. At 25 items it is larger than Data & charts (17) and
Blocks (13), each of which is already a section holding a single shelf, so the
catalog's own precedent puts it one level up.

Pulling it out also takes the largest section in the catalog from 120 items to
95, which is the reason the shelf was added in the first place.

The two circle-5 pages change because their embedded source block carries the
recentred path; nothing else in them moved.

---------

Co-authored-by: Jake Moran <jake.moran@heygen.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-09 00:47:31 -04:00
miga-heygen 6e3308be4f chore: release v0.8.33 (#3796)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-08 22:12:51 -04:00
miga-heygen 662f96b3f4 chore: release v0.8.32 (#3788)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-08 19:40:29 -04:00
Miguel Ángel 0ce0bb13eb fix(runtime): preserve fractional final-frame visibility (#3696)
* fix(runtime): preserve fractional final-frame visibility

* test(runtime): cover repeated nested fractional tails
2026-09-08 18:11:40 +00:00
Miguel Ángel 01744f2b0c fix(media): unify nested start coordinate mapping (#3732) 2026-09-08 18:10:51 +00:00
Miguel Ángel 00c91d6807 fix(check): allow missing caption overrides (#3716) 2026-09-08 18:09:35 +00:00
Miguel Ángel 30d6f43bdb chore: release v0.8.31 (#3747)
* chore: release v0.8.31

* docs(release): describe the range fix on its own terms
2026-09-07 12:12:35 -04:00
Miguel Ángel 3874990449 chore: release v0.8.30 (#3733) 2026-09-05 23:14:12 -04:00
James Russo b94b5bde58 fix(core): bound timing compiler opening tag scans (#3719)
* fix(core): bound timing compiler opening tag scans

* fix(core): bound ID-targeted duration tag scans
2026-09-05 18:02:36 -04:00
James Russo e78da303f4 fix(core): bound inert region scans in timing compiler (#3717)
* fix(core): bound inert region scans in timing compiler

* fix(core): use literal search for comment terminators

* fix(core): recognize end-bang HTML comment boundaries
2026-09-05 16:40:39 -04:00
James Russo eec04340b3 fix(core): avoid grade stats regex backtracking (#3706) 2026-09-05 12:28:59 -04:00
miga-heygen ae3d80c30f chore: release v0.8.29 (#3690) 2026-09-04 21:50:46 -04:00
Miguel Ángel 64ce9fdf1f chore: release v0.8.28 (#3689) 2026-09-04 21:01:08 -04:00
James Russo 65cf87de38 fix(core): scope composition root pattern selectors per instance (#3684)
Port the authored-root selector fix from Thomaswebstich in #2262 to current main.

Co-authored-by: Thomaswebstich <thomas.schnerb@gmail.com>
2026-09-04 20:27:56 -04:00
James Russo 893141413e docs(core): fix published frame adapter reference links (#3667)
Co-authored-by: yoma <yingwaizhiying@gmail.com>
2026-09-04 19:30:59 -04:00
Miguel Ángel c8ddbe0a4b fix(runtime): align nested activation to export frames (#3640) 2026-09-04 23:14:50 +00:00
James Russo 723cd0d785 fix(studio-server): block dangling symlink upload escapes (#3661)
* fix(studio-server): block dangling symlink upload escapes

* fix(studio-server): contain rename reference updates
2026-09-04 19:03:08 -04:00
James Russo bec11b1293 fix(runtime): wait for a finite dotlottie duration (#3651) 2026-09-04 17:37:53 -04:00
Miguel Ángel 19ab83f929 chore: release v0.8.27 (#3608) 2026-09-03 00:27:31 -04:00
miga-heygen 1a788d62e7 fix(core): catch PostCSS parse errors instead of dropping compositions (#3589)
* fix(core): catch PostCSS parse errors instead of dropping compositions

Invalid CSS in a sub-composition style block made postcss.parse throw
inside scopeCssToComposition. The throw propagated to the composition
loader's catch block, which emptied the host — silently dropping the
entire scene. Lint swallowed the same error via catch { continue },
reporting 0 warnings.

Two fixes:
- Runtime: wrap postcss.parse in try/catch and return the original
  (unscoped) CSS on failure, so the composition still mounts
- Lint: emit a css_parse_error finding instead of silently continuing

Fixes #3585.

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

* fix: drop unparseable CSS instead of leaking it unscoped

Return "" on PostCSS parse failure so sub-composition stylesheets
that cannot be scoped are dropped rather than injected unscoped into
the parent document. Updates test fixture to use valid+malformed CSS
that demonstrates the leak risk.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-09-03 04:22:21 +00:00
Miguel Ángel 84ed587f33 chore: release v0.8.26 (#3597) 2026-09-02 10:36:01 -04:00
Miguel Ángel 6b360f56f7 chore: release v0.8.25 (#3595) 2026-09-02 01:29:06 -04:00
James Russo aceaaebd68 chore: release v0.8.24 (#3593) 2026-09-01 21:54:42 -04:00
Miguel Ángel 6cbe3fbe90 chore: release v0.8.23 (#3586) 2026-09-01 13:58:14 -04:00
Miguel Ángel 38e356fba4 chore: release v0.8.22 (#3575)
* chore: release v0.8.22

* docs: include encoder retry in v0.8.22 notes

---------

Co-authored-by: James <james.russo@heygen.com>
2026-08-31 22:54:13 -04:00
Miguel Ángel f3099dcb27 chore: release v0.8.21 (#3570) 2026-08-31 15:16:22 -04:00
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