Commit Graph
4279 Commits
Author SHA1 Message Date
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 cd1a996ba4 fix(catalog): confine hosted preview asset downloads (#3843)
* fix(catalog): confine hosted preview asset downloads

* test(catalog): pin credential and port refusal with finite streams
2026-09-10 08:21:49 -04: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 a7e2e3853a fix(media): validate every download redirect target (#3841)
* fix(media): validate every download redirect target

* fix(media): apply redirect policy to logo HEAD probes
2026-09-10 06:16:25 -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
James Russo 962c95406d fix(registry): verify staged asset content before publishing manifests (#3832)
* fix(registry): verify staged asset content before publishing manifests

* test(registry): run asset staging regression in script CI

* refactor(registry): separate verified manifest publication
2026-09-10 04:28:58 -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 41ba820d65 Merge pull request #3806 from heygen-com/docs/prinfra-345-webm-alpha-inspection
docs(render): explain alpha-aware WebM inspection
2026-09-09 00:47:05 -07: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 a153e630bd Merge pull request #3801 from heygen-com/fix/prinfra-612-browser-init-retry
fix(render): retry timed-out browser initialization once
2026-09-09 00:45:17 -07:00
Vance Ingalls 78e84a9ca8 docs(render): explain alpha-aware WebM inspection 2026-09-09 00:00:24 -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
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-heygenandMiguel Ángel 6e3308be4f chore: release v0.8.33 (#3796)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
v0.8.33
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-heygenandMiguel Ángel 662f96b3f4 chore: release v0.8.32 (#3788)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
v0.8.32
2026-09-08 19:40:29 -04:00
James Russo efe2404cbd fix(skills): stage media downloads in private directories (#3787) 2026-09-08 19:29:26 -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
James Russo a7bf0d23d9 fix(skills): retain audio metadata file identity during generation (#3778)
* fix(skills): retain audio metadata file identity during generation

* test(skills): isolate audio metadata CLI credentials
2026-09-08 17:35:12 -04:00
James Russo a92dc6865e fix(skills): read BGM logs without pathname prechecks (#3769) 2026-09-08 16:40:20 -04:00
James Russo d66cd6dcc8 fix(catalog): publish cache assets without overwriting entries (#3739)
* fix(catalog): publish cache assets without overwriting entries

* fix(catalog): publish cache assets without overwriting entries
2026-09-08 14:54:27 -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