Commit Graph

3226 Commits

Author SHA1 Message Date
heygen-rui-bot f0a751ee59 codegen: resync cloud client from EF 62134ca4 2026-09-11 01:06:33 +03:00
Miguel Ángel 59def1b66d chore(release): v0.8.34 (#3854) 2026-09-10 14:45:17 -04:00
Miguel Ángel 9549e038c4 perf(studio): a paused editor stops redrawing its overlays sixty times a second (#3846)
* perf(studio): run the editor overlays on one parkable frame loop

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

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

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

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

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

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

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

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

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

---------

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

* test: locate caption data without an HTML filtering regex
2026-09-10 12:53:30 -04:00
Miguel Ángel 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 0408fbcd99 perf(studio): scrubbing a large composition no longer stalls the editor (#3842)
* perf(studio): ask each ancestor once per off-canvas rebuild, not once per element

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

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

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

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

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

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

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

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

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

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

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

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

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

This one fades a wrapper between two rebuilds and asserts the marker under it
goes away. Visibility through ancestors IS memoized, so the pass surviving the
rebuild serves the stale answer: with `createOverlayMeasurePass()` hoisted the
suite exits 1 on this test alone, and exits 0 at head.
2026-09-10 14:48:30 +00:00
James Russo 96637aeb1c fix(capture): share the render host policy and block localhost aliases (#3844) 2026-09-10 07:59:30 -04:00
James Russo d393541c2b fix(cloud): preserve existing output when downloads fail (#3840)
* fix(cloud): preserve existing output when downloads fail

* test(cloud): pin same-directory download staging
2026-09-10 05:44:40 -04: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 622ea5b38c test(skills): pin the catalog exemption as a capability and lint the templates list (#3838)
The two skills exempt from the catalog search were pinned only by the
sentence that declares the exemption, so the exemption would go stale the
day either gained a way to install a registry item. The test now also
asserts neither skill contains an install command or a registry path.

The templates list in the scaffold reference claimed to be lint-checked
but lacked the marker that arms the check, so a misspelt template would
have passed. It now carries the marker with its presets and skill names
allowlisted, and a misspelt template fails the lint.
2026-09-10 03:49:21 +00:00
Miguel Ángel 0f8eb892e3 fix(skills): search the component catalog before hand-building a look (#3829)
* fix(motion-graphics): make the catalog search fire before hand-authoring

The workflow's only reuse instruction pointed at catalog-map.md, a
hand-maintained snapshot of ~60 registry items, and no file in the skill
ever named `hyperframes catalog --query`. An agent asked mid-build for
CRT scanlines and a glitch effect had no instruction to search, so it
hand-authored both while caption-glitch-rgb ("RGB chromatic aberration
with CRT scanline overlay") ranks first for that query on either tier.

The search reads the hosted registry and needs nothing installed, from
any directory with no project, so "the components were not installed"
was never the cause. Say that where the reader is, since the wrong
diagnosis is the intuitive one.

Director Part 2 and the Builder now run the search before naming a
block, and catalog-map.md is labelled a partial snapshot whose misses
prove nothing. Pinned by a content test in coreSkillContent.test.ts.

* fix(skills): search the component catalog before hand-building a look

Authoring workflows never told the agent to search the component library,
so agents rebuilt effects the registry already shipped. A user reported
building an effect from scratch that the registry already contained; the
search that would have found it needs nothing installed, which is why the
usual self-diagnosis ("I forgot to install the components") is wrong.

All ten workflow skills carried zero mentions of `hyperframes catalog`.
The instruction lived only in hyperframes-cli and hyperframes-registry,
both loaded on demand, and the registry skill's own trigger named the
command rather than the symptom - circular, because an agent that never
thought to search could not reach the doc telling it to search.

- Eight workflows now run the search at the point they decide what to
  build, before authoring. The two that compile through a closed
  authoring vocabulary (embedded-captions, talking-head-recut) document
  why they deliberately do not.
- hyperframes-registry triggers on the symptom (a named look, effect,
  treatment or transition) instead of the command name; the router table
  and the catalog surfaces carry the same framing.
- Fixes hand-maintained lists that had drifted: bar-chart-race was listed
  as a hand-author gap in two files while shipping in the registry;
  stat-motion was named as an installable block and is not one; the
  caption-* family count was one high; the registry discovery tables
  claimed to be the block list while covering 97 of 180.
- bun run lint:skills now fails when a doc marked as a registry snapshot
  names an item the registry does not have.

* refactor(scripts): reuse native recursive readdir and the shared registry type

Simplify pass on the new registry-snapshot check, behaviour identical:
- collectMarkdownFiles uses readdirSync({ recursive: true }) instead of
  hand-rolled recursion, matching scripts/generate-template-previews.ts.
- registryItemNames types registry.json with the exported RegistryManifest
  instead of an ad hoc inline shape, matching scripts/catalog/build-local-vectors.ts.
  The runtime guard stays: a cast describes the file, it does not validate it.
- One report() helper replaces the duplicated print-and-count block in both
  lint passes.

* refactor(scripts): name the registry check's blind spots and stop self-arming

Applies the review findings on the new check, behaviour identical except
where noted:
- lintRegistryItemRefs returns null for an unmarked file instead of an
  empty array, so "not a snapshot" and "a clean snapshot" have one owner
  and the marker is matched once rather than twice.
- Marker detection ignores fenced blocks, so a doc that documents the
  marker syntax in an example no longer arms the check on itself. The id
  scan still reads full content, so fenced examples stay covered.
- The header comment and two tests now pin both known false negatives:
  identifiers outside backticks, and single-word item names. Measured on
  the six marked files, dropping the hyphen requirement would monitor 3
  more items and force 46 allow= entries for ordinary prose words, so the
  requirement stays and the gap is stated instead of silent.

* chore(skills): regenerate skills manifest after catalog-search edits
2026-09-10 03:21:33 +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 ac27b1534f perf(studio): resolve a selector's occurrence index once per layer walk (#3831)
The preview flattens several composition files into one DOM, so a layer's
identity is its selector plus its occurrence index WITHIN its own source
file. `getSourceScopedSelectorIndex` derived that per element: a whole
-document `querySelectorAll(selector)`, `resolveSourceFile` on every match,
then `indexOf`. Every element sharing a class paid for all of them, so a
walk over n such elements did n whole-document queries and n^2 source-file
resolutions — the shape a composition of repeated cards or tiles has by
construction.

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

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

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

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

Tests assert complexity invariance rather than a threshold: the query count
must be IDENTICAL at n and 4n elements sharing a selector, which a fixture
-sized threshold would not catch. Both fail on the previous algorithm.
2026-09-09 19:11:22 -07:00
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 0665249ca9 fix(playground): render initialization errors as text (#3825) 2026-09-09 19:57:39 -04: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 8d80530ff4 fix(player): authenticate slideshow navigation senders (#3820)
* fix(player): authenticate slideshow navigation senders

* test: isolate playground message boundary imports
2026-09-09 15:25:38 -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 caa72de355 fix(cli): bound and validate registry installation (#3814)
* fix(cli): bound and validate registry installation

* fix(cli): preserve registry install compatibility
2026-09-09 12:41:48 -04:00
James Russo b8328f9573 fix(core): authenticate runtime control message senders (#3813) 2026-09-09 10:26:56 -04:00
James Russo 4f77c282da fix(studio): authenticate preview message senders (#3812) 2026-09-09 09:32:03 -04:00
James Russo 26916b7bdd fix(cli): validate captured Lottie archives and previews (#3811)
* fix(cli): validate captured Lottie archives and previews

* fix(cli): bound Lottie discovery before buffering

* fix(cli): bound Lottie discovery lifecycle and archive paths

* fix(cli): retain highest-priority Lottie candidates
2026-09-09 08:50:48 -04:00
James Russo 1aa5b9e4fa fix(cli): validate captured image and font downloads (#3809)
* fix(cli): validate captured image and font downloads

* fix(cli): bound nested assets and preserve distinct fonts
2026-09-09 07:15:29 -04:00
James Russo f54ba56136 fix(studio): contain project IDs across client and server routes (#3808)
* fix(studio): contain project IDs across client and server routes

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

* fix(studio): reject drive-relative IDs and filter project discovery
2026-09-09 05:27:36 -04:00
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
Vance Ingalls 6af3df6f1f Merge pull request #3804 from heygen-com/fix/prinfra-353-jpeg-input-validation
fix(engine): reject malformed JPEG input before encoding
2026-09-09 00:46:06 -07:00
Vance Ingalls e0a06f638c Merge pull request #3802 from heygen-com/fix/prinfra-344-browser-executability
fix(cli): verify Chrome can execute during preflight
2026-09-09 00:45:26 -07:00
Vance Ingalls b22afd6bbc fix(engine): reject malformed JPEG input before encoding 2026-09-08 23:51:00 -07:00
Vance Ingalls c2702e4c76 fix(cli): verify Chrome can execute during preflight 2026-09-08 23:42:57 -07:00
Vance Ingalls d0ee207b06 fix(render): retry timed-out browser initialization once 2026-09-08 23:40:09 -07: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
James Russo 73e791178c fix(producer): isolate regression harness temporary roots (#3799) 2026-09-09 00:45:23 -04:00
James Russo c7891a9364 fix(producer): isolate assembly scratch directories (#3798) 2026-09-09 00:02:41 -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 1aaa35d073 fix(engine): detect onUpdate callbacks and enable verification events (#3794)
The static-frame dedup predictor walked tween intervals from
window.__timelines but was blind to onUpdate callbacks — motion driven
from a timeline's onUpdate in a tween-free window was predicted static.
The verifier compounded the gap by seeking with suppressEvents: true,
so the onUpdate never fired and the frozen frame passed verification.

Two changes:
1. Predictor: when a timeline carries vars.onUpdate, mark its full span
   as animated so those frames are never predicted static.
2. Verifier: seek with suppressEvents: false so the verification page
   behaves identically to the capture page. The verification page is
   already isolated (separate Page instance), so out-of-order event
   side effects cannot corrupt sequential capture.

Fixes #3793
2026-09-09 01:57:36 +00: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
James Russo 91a34ffc8a fix(studio): claim upload filenames exclusively (#3786)
* fix(studio): claim upload filenames exclusively

* fix(studio): write uploads through exclusive descriptors
2026-09-08 18:51:58 -04:00
Miguel Ángel 65cc1d6023 fix(shader): keep vertex shaders within their WebGL context (#3672) 2026-09-08 18:12:28 +00:00
Miguel Ángel e1fda7866e fix(engine): use supported VideoToolbox quality options (#3687) 2026-09-08 18:12:07 +00: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 6c0ee41ac4 fix(lint): flag autoAlpha on clip elements (#3692) 2026-09-08 18:11:24 +00:00
Miguel Ángel c5b7f6aeab fix(check): retry system Chrome after managed launch crash (#3701) 2026-09-08 18:11:13 +00:00
Miguel Ángel 4a72a98a49 fix(player): pause the hidden loader sheen (#3673) 2026-09-08 18:11:02 +00:00
Miguel Ángel 01744f2b0c fix(media): unify nested start coordinate mapping (#3732) 2026-09-08 18:10:51 +00:00
Miguel Ángel e5a6023228 fix(skills): guard canonical stores from mirror aliases (#3714) 2026-09-08 18:10:44 +00:00