* feat(media-use): resolve brand logos from theSVG
Replace the svgl and simple-icons tiers of the `logo` cascade with a
single theSVG (thesvg.org) tier. theSVG ships 7,400+ official brand
SVGs with color, mono, light/dark and wordmark variants.
The tier fetches one manifest pinned to a glincker/thesvg commit on
jsDelivr, memoizes it per process, and matches entities locally by
normalized slug, title, or alias. That drops the hand-kept svgl alias
and simple-icons slug maps. GitHub org avatar and favicon stay as the
fallbacks for brands theSVG lacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(media-use): sanitize fetched SVGs and gate theSVG by license
freezeUrl/freezeLocalFile wrote every fetched SVG to disk unsanitized;
the theSVG tier is now the sole SVG-returning logo provider, so an
inline script tag or on-event handler would land verbatim in a user
project. Route .svg destinations through the existing Figma sanitizer
(new @hyperframes/core/sanitize-svg subpath, synced via
package-subpaths).
thesvgMatch never checked theSVG's own license field: entries theSVG
itself marks unresolved (Proprietary, Unknown, Custom, TODO, or an
explicit no-redistribution-license string) could still resolve and
freeze into a project. Gate matches to the ten SPDX ids theSVG's
submission form accepts; a rejected entry falls through to a lower
ranked theSVG match, then to the github/favicon tiers.
Also caps the theSVG manifest fetch at 20MB, streamed, matching
freeze.mjs's existing cap discipline. It had a timeout but no bound.
* chore(skills): regenerate skills-manifest for media-use/motion-graphics
The original submission edited skills/media-use and
skills/motion-graphics docs without regenerating skills-manifest.json;
its own CI never ran (fork approval gate), so the drift went
unnoticed until this pass's first CI run caught it.
* fix(media-use): drop the sanitizer's workspace import, add parity test
freeze.mjs importing @hyperframes/core/sanitize-svg broke the
test-skills CI lane: packages/cli/src/media-use tests run with node
builtins only, zero install, by design (media-use is vendored
standalone into other projects, e.g. packages/desktop). Port the
sanitizer into media-use/lib/svg-sanitize.mjs instead, and drop the
now-unused core export.
Parity between the port and packages/core/src/figma/sanitizeSvg.ts is
enforced by a new test (scripts/check-svg-sanitize-parity.test.mjs,
run in the test:scripts lane, which does have an install) over a
hostile-SVG corpus, so the two cannot drift silently.
* fix(media-use): close three sanitizer bypasses, add fetch timeout
Independent review of the maintainer pass found the SVG sanitizer
(packages/core/src/figma/sanitizeSvg.ts, reused by media-use's own
port) was itself bypassable: an unquoted href/xlink:href survived,
inline style="..." attributes were never checked, and a href bound
through a renamed XML namespace prefix (xmlns:x=".../1999/xlink",
x:href=...) slipped past the literal "xlink:href" match.
Rewrote sanitizeSvg as a tag/attribute tokenizer instead of a set of
literal-spelling regexes: parse each tag's attributes in any quoting
form, resolve the local name after stripping any namespace prefix,
then drop by local name (any on* handler, any *href whose value is
not a #fragment or data:image/ embed, any style attribute containing
url(, @import, expression( or javascript:). Ported the fix into
media-use's svg-sanitize.mjs identically. Legitimate url(#id)
references on non-style attributes (clip-path, fill) are untouched.
Also adds a 10s AbortSignal.timeout to freezeUrl's fetch, matching
every other fetchMedia call in this package - it had a size cap but
no deadline.
New cases for all three bypasses in both sanitizeSvg.test.ts and the
parity corpus, each reproducibly failing against the prior head.
* fix(media-use): replace regex SVG sanitizer with a DOM allowlist
Two rounds of regex/tokenizer patches to sanitizeSvg found new bypasses
in the same shape each time (unquoted href, style= url(), aliased
xlink prefix; then presentation-attribute url(), unquoted '=' handling,
SMIL animate/set href rewrite, self-closing-slash tag corruption,
data:image/ navigation via <a>). Denylisting attribute names against a
hand-rolled tokenizer was the wrong approach regardless of how careful
the regex.
Rewrote sanitizeSvg (packages/core/src/figma/sanitizeSvg.ts) as a real
DOM walk (linkedom, already a dependency of core and cli) with an
allowlist: known-safe elements and attributes only, everything else
dropped by construction rather than pattern-matched as dangerous.
href/xlink:href (any namespace prefix) kept only for a same-document
fragment, or data:image/ off non-<a> elements. Any attribute value or
<style> text containing url() kept only when every url() argument is a
fragment; @import/expression(/javascript: dropped from style content.
Serializing from a real DOM means output is always well-formed, so the
self-closing-slash corruption class of bug is gone by construction.
media-use's own copy (packages/cli/src/media-use/lib/svg-sanitize.mjs)
can't import linkedom directly: its CI test lane runs bare `node --test`
with no install step, so even an external dependency fails to resolve,
not just a workspace import. It's now a generated file instead of a
hand-copy: scripts/generate-media-use-svg-sanitize.mjs bundles the core
source (esbuild, minified, zero remaining imports) and
scripts/check-media-use-svg-sanitize-generated.test.mjs fails the build
on drift. Verified the generated file runs with plain node in an empty
directory with no node_modules on the path.
Every payload from both independent-review reports is now a
table-driven test in sanitizeSvg.test.ts (46 cases) and the same corpus
in check-svg-sanitize-parity.test.mjs (39 cases) asserts core and the
generated media-use copy agree and each independently rejects every
non-local url(), javascript:, data: href on <a>, and on* handler.
Mutation-proved against the real pre-fix blob, not a synthetic case.
Sampled 56 real theSVG icons across all 7 collections to check the
allowlist doesn't over-strip legitimate content: found and fixed three
missing attributes (fx/fy, enable-background, a blanket data-*
allowance) and one real behavioral bug - <style> elements were being
dropped outright, silently unstyling any Illustrator-exported logo that
colors paths via CSS classes. Fixed by keeping <style> on the allowlist
and content-checking its text the same way as an inline style=
attribute, with a regression test using that exact shape.
Full suite after the fix: sanitizeSvg.test.ts 46/46, parity+drift tests
39/39, media-use suite 298/298, tsc/oxlint/oxfmt clean.
* fix(lint): ignore generated svg-sanitize.mjs bundle in oxlint
Minified esbuild output (packages/cli/src/media-use/lib/svg-sanitize.mjs,
generated by scripts/generate-media-use-svg-sanitize.mjs) trips oxlint's
correctness rules 504 times and fails `bun run lint`. Same shape as the
existing registry/blocks/**/*.iife.js exclusions: a generated, minified,
non-hand-edited bundle, not hand-written source.
* fix(format): ignore generated svg-sanitize.mjs bundle in oxfmt
Same generated esbuild bundle that needed an oxlint exclusion in the
previous commit also fails (minified, no formatting to
check against), cascading Preflight into Player perf, regression,
Windows render verification, and preview-regression on CI. Same
established pattern as the existing registry/blocks/**/*.iife.js and
skills/**/*.min.js entries in .prettierignore.
* refactor(media-use): reduce complexity flagged by fallow audit
isSafeAttribute (10 cyclomatic) and the parity test's aggregate-marker
check (11 cyclomatic, 26 cognitive, CRAP 132) both exceeded fallow's
per-function thresholds after the rebase pulled in a stricter baseline.
Extracted isXmlnsAttribute/isKnownAttribute as named predicates and
survivingUnsafeMarkers as a named per-output helper; no behavior change,
same guard order, same failure strings. Regenerated svg-sanitize.mjs.
* refactor(media-use): make the parity test's marker check table-driven
Fallow gives .mjs scripts no coverage credit, so its CRAP gate needs each
function at cyclomatic 4 or less. The extracted survivingUnsafeMarkers
still scored 9 (CRAP 90). The four regex markers are now a table, and the
data:image-on-<a> and non-local url() checks are small named helpers.
Same checks, same order, same failure strings.
* fix(media-use): close CSS-escape and image-set bypasses in sanitizeSvg
The url()/@import checks scanned raw text for the literal "url(" and
"@import". A browser decodes CSS escapes first, so fill="\75 rl(...)",
url\28...\29 and @\69mport all loaded an external resource, and
image-set("https://...") loads one with no url() at all. Reproduced in
headless Chrome: 8 of 8 payloads fired a request through the old code.
CSS can only load a resource through a function or @import, so CSS text
now passes only if it has no @import, every function is on a compute-only
allowlist (color, transform, filter and math functions), and every url()
is a same-document fragment, all checked on escape-decoded, lowercased
text. Same fix reaches media-use through the regenerated bundle.
Checked against all 13,384 files of the pinned theSVG corpus: 13,383
sanitize byte-identically; one drops an unreferenced id="XRP-(XRP)".
The parity oracle now fails on any surviving attacker host, so it no
longer shares the sanitizer's parsing blind spot.
* refactor(media-use): share one capped body reader for freeze and manifest
fetchJson copied freezeUrl's declared-length check and streaming size cap
line for line. Both now call readCappedBody in media-fetch.mjs, which both
already import; it has its own tests, since freeze's cap had none. The
freeze cap errors now read "freeze failed for <url>: ..." (no caller or
test matches the old wording). Also fixes freeze.mjs's comment, which
still called the sanitizer a lexical pass.
* style(media-use): format the parity test corpus
* fix(media-use): make the sanitizer's CSS scans linear on hostile input
The url() check used /url\(\s*(['"]?)([^)]*)\1\s*\)/ and the function
check /([-\w]*)\(/: on attacker padding these backtracked cubically and
quadratically (an unclosed url( plus 4,000 spaces took 20s; an 80,000
character word took 7.7s), and freeze runs the sanitizer synchronously on
bodies up to 256MB. Both are now single-pass indexOf scans (a million
characters in under 15ms), with the same verdict on ordinary input. A url()
value holding "(" is rejected, so a nested url( can't hide inside one.
A size test holds both payloads under 2s.
* test(media-use): size payloads that fail the old scans fast instead of hanging
* fix(media-use): stop the freeze timeout from aborting long downloads
The 10 s AbortSignal.timeout on freezeUrl also aborted the response body,
so any stock video, generated video or LUT that took longer than 10 s to
download failed. The timer now covers only the wait for response headers
and is cleared once they arrive; the 256 MB cap still bounds the body.
* refactor(media-use): trim logo-provider comments to the repo limit
The tier list and the thesvgMatch doc block ran past the four-line comment
cap that the merge check enforces. Same facts, shorter blocks.
* refactor(media-use): name the header wait and restore the variants note
Renames the freeze timeout controller to headerWait so it no longer reads
as HTTP headers, puts the theSVG variants fact dropped by the comment trim
next to the line that picks the default mark, and keeps thesvgMatch on the
file's JSDoc style.
---------
Co-authored-by: Imad Phelps <imad.phelps@heygen.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix(core): looping Lottie animations keep cycling instead of freezing after one pass
* feat(registry): add a Lottie character walk-in and document the character route
* chore(registry): index lottie-character-walk in the catalog and search vectors
* chore(docs): list lottie-character-walk in the catalog gallery
* docs(skills): say that lottie-web loops when loop is left out
* fix(core): a finished Lottie holds its last frame instead of disappearing
* fix(core): hold a finished Lottie on its exact last frame
* chore(skills): refresh the manifest hash for the Lottie skill edits
* fix(core): wrap looping Lottie by frame so a loop never holds at its seam
* fix(core): a looping Lottie starts each cycle on frame 0 despite float error
* feat(core): data-vfx-chain defs and chain parsing (mirrors data-fx-chain)
Mirrors audioFx.ts's shape for the per-pixel VFX chain: a versioned
JSON chain attribute (data-vfx-chain), a def registry with typed
params (number/enum/bool/ref), and parse/serialize/normalize helpers.
HF_VFX holds one def, fractal-noise, with a placeholder frag; the
kernel and its consumers (packages/core/src/runtime/vfx.ts) land in
Task 1.2/1.3 of the vfx-chain-runtime plan, so vfx.ts is added to
.fallowrc.jsonc's entry list for now.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(runtime): paint data-vfx-chain canvases on every seek
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(vfx): fractal-noise kernel (Basic/fBm approximation) with CPU reference
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(vfx): self capture through layoutsubtree with the page-composite readiness protocol
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(vfx): wave-warp kernel (Sine)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(vfx): displacement-map kernel (self-referential map)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(vfx): chain order is nesting order; preceding CSS effects are captured
...and following ones apply outside. The plan's one-line message for Task 2.4
is 105 characters; commitlint caps the header at 100, so the tail moved here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(vfx): seek determinism and CPU-reference PSNR for fractal-noise
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(vfx): size the output canvas from the host's layout box, not its transformed AABB
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(telemetry): vfx node count, capture class and types on render_complete
Threads vfxNodeCount, vfxCapture and vfxTypes from a static scan of the
compiled composition's data-vfx-chain hosts (mirrors the existing
compositionElementCount/colorGradingCount scan) through
RenderPerfSummary.drawElement into the render_complete event, next to
de_capture_mode. Exports chainCapture/parseVfxChain/HfVfxCapture from
@hyperframes/core's index for the producer to consume. The PostHog
dashboard tile is not created here — left for the coordinator.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs(vfx): data-vfx-chain guide — element shape, capture table, Studio flag, known constraints
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(vfx): register chain hosts that arrive with a sub-composition mount
`initVfx(document.body, fps)` ran once, synchronously, three lines after
`void loadExternalCompositions(...)` — so every `data-vfx-chain` host that
enters the DOM only when a `data-composition-src` mount resolves was never
registered. An unregistered host paints nothing and reports nothing: no
`[HyperFrames] composition script error`, the `.hf-vfx-out` canvas keeps its
300x150 default, and `paintVfx` never even arms the page-composite flag.
Measured on a real multi-file export served raw: `#main-l5-text`'s output
canvas stayed 300x150 and `window.__timelines` never gained the sub-comp.
Re-scan in the loader's `.finally`, before `maybePublishRenderReady()`, so
registration precedes readiness. `initVfx` now releases the outgoing
registry's programs, capture textures and ping-pong targets first — the
output canvas hands back the same WebGL2 context on a re-scan, so the
previous pass's GL objects would otherwise leak.
Two browser cases in `vfxDeterminism.test.ts`, both against the real runtime
bundle: a host whose `.hf-vfx-in` IS the mount (already worked — the mount
replaces the wrapper's children, not the wrapper, so the registered source
node survives), and a host that arrives INSIDE a mounted sub-composition
(failed with `armed: false` and no error; passes now).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(vfx): runtime tsconfig typecheck passes
Under noUncheckedIndexedAccess, resolveCaptureSource's null return
widened VfxEntry.src past its VfxCaptureSource | undefined type; the
function is internal-only, so its return type moves to
VfxCaptureSource | undefined instead. Indexing PingPong.framebuffers
by a non-literal index produced | undefined against
WebGLFramebuffer | null, coalesced to null. No behavior change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(vfx): structural DOM predicates instead of realm-bound instanceof
vfx.ts checked `instanceof HTMLCanvasElement`/`HTMLElement` directly, which
returns false for inner-realm nodes when the Studio previews across an
iframe realm. domRealm.ts had no canvas predicate yet, so this adds
isCanvasElement mirroring the existing isImageElement pattern, then swaps
all three vfx.ts checks to the structural predicates the lint guard requires.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix(vfx): paint self-capture hosts under hyperframes snapshot, not only the engine path
`paintVfx(t, { engineMode: true })` armed the page-composite protocol and
returned, betting that every capturing chain runs under `frameCapture.ts`.
`hyperframes snapshot` (and check/compare/validate/layout, and Studio's
thumbnail capture) seek through the same `renderSeek`, never read
`__hf_page_composite_pending` and never call `__hf_page_composite_resolve` —
so a `self` chain painted nothing there, in silence, because its content
lives in a `layoutsubtree` canvas the browser never paints directly.
Engine mode now arms the preview-side capture as well and paints on whichever
completes first. A seek token stops a superseded async capture from repainting
a newer frame, and a registry-identity check stops it painting an entry
`initVfx` already released. `resolveVfxCapture` stays non-idempotent on
purpose: the engine resolves only after its own before-capture hooks have
mutated the DOM, so its capture is still the authoritative one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(vfx): an exporter-emitted .hf-vfx-out gets the box the spec fixes for it
findOrCreateOut adopted a canvas the exporter had already emitted without
applying the layout cssText, so it kept the default position:static;
display:inline and wrapped to the line AFTER the layer-wide .hf-vfx-src
canvas — painting a second, misplaced copy of the layer one full layer
height below the host. Measured on retro-wave #main-l6-text: .hf-vfx-src
rect top 315.1 h 93.0, .hf-vfx-out rect top 409.2, both style=null,
position static (ae-mcp findings Task 3.5b). Hosts with no emitted
out-canvas were never affected, which is why only this one host moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(telemetry): vfx_node_count counts hosts, not chain nodes — rename to vfx_host_count
The field increments once per `data-vfx-chain` attribute occurrence, never
per node inside a chain's JSON, so the name promised something the scan does
not measure. Renames the internal `vfxNodeCount` and the wire property
`vfx_node_count` to `vfxHostCount`/`vfx_host_count` across the orchestrator
scan, the perf summary, the CLI render path and the telemetry event. No
dashboard consumes the field yet, so the wire rename costs nothing now and
would cost a migration later.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(vfx): join the preview capture to the seek-completion barrier, not race it
`paintVfx` fired `capturePreviewThenPaint` and forgot it, so nothing the CLI
awaits waited for a `self` chain's paint. Every snapshot-family command
(`snapshot`, `check`, `compare`, `validate`, `layout`) seeks through
`seekCompositionTimeline`, which drains `window.__hfWaitForSeekCompletion`
and then allows one settle race before screenshotting — and only the
`"hf-seek"` event's `waitUntil()` could put anything in that barrier. The
screenshot therefore landed on however many hosts happened to have finished,
which varied with host count and machine speed.
Extracts the "track, untrack on settle, retain the first rejection" block
`dispatch()` built inline into an exported `registerSeekCompletion`, and has
`paintVfx` register the preview capture through it. Also awaits the per-host
paints with `Promise.all` instead of sequentially: N hosts cost about two
animation frames now, not 2N.
Measured on the pre-fix bundle with two `self` hosts, both output canvases
read `[0,0,0,0]` at the moment the barrier returned; they painted only
because the settle race that follows happened to be long enough. Both tests
added here fail on that bundle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(vfx): report WebGL context loss loudly instead of silently no-op painting
One WebGL2 context per `data-vfx-chain` host, against a browser cap that is
commonly 16 and shared with `colorGrading.ts`'s per-element contexts. A lost
context makes every later call on it do nothing, per spec — so the chain kept
"painting" a blank canvas with no error, which is exactly what the module's
loud-failure contract exists to prevent.
Adds the `webglcontextlost` listener pair's first half, following
`colorGrading.ts`'s precedent: `preventDefault()`, mark the entry, report
once through `reportVfxError`. The entry is then skipped by `paintVfx`,
`capturePreviewThenPaint` and `resolveVfxCapture` — silently, since the one
loud report already happened and repeating it per frame is spam.
Restore is left unhandled on purpose, with the reasoning in the doc comment:
`initVfx`'s existing re-scan already rebuilds every GL object, and a second
narrower rebuild path for `webglcontextrestored` would duplicate it. The
shared-context redesign the review floated is likewise out of scope here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* perf(vfx): cache uniform locations at pass-build time instead of querying every paint
`setPassUniforms` asked the driver for `u_size`, `u_t`, `u_fps` and every
param's location on every paint of every pass, and `bindPass` did the same
for `u_src` on every bind. `buildPasses` already constructs each `VfxPass`
once at init, right after the program links, and a linked program's
locations never change — so they are resolved there now and read from
`pass.locations` in the hot path.
Same values, same `null`-means-optimised-out semantics (`gl.uniform*(null,
…)` is a defined no-op, which is what the per-frame query produced too), so
the existing vfx.test.ts and the browser determinism suite cover it
unchanged — both still green, the latter against a rebuilt runtime bundle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(vfx): bound the paint wait per host so a stalled compositor can't hang the seek barrier
`paintVfx` registers `capturePreviewThenPaint` into the shared seek-completion
barrier (48aaa2bbb), and that promise settled only via `awaitCanvasPaint` — a
race between the canvas `paint` event and a double-rAF chain. Under BeginFrame
control (Linux headless-shell, `drawelement` capture) neither can fire before
the barrier is drained: `frameCapture.ts` awaits the barrier in
`prepareFrameForCapture` (~2692), while the frame's
`HeadlessExperimental.beginFrame` is issued later, in the per-frame capture
stage (~3797). Fire-and-forget that was harmless; registered it can stall every
`__hfWaitForSeekCompletion` caller, the engine's render path included.
rAF-under-BeginFrame: CONFIRMED gated, from this repo's own engine notes —
`frameCapture.ts` ~2445 ("waitForFunction uses rAF polling internally, which
won't fire in beginFrame mode") and ~4755 ("headless only fires rAF when a
frame is produced, and nothing produces one until a screenshot asks"). Not
reproduced locally: every environment here is macOS with a free-running
compositor (`beginFrameTimeTicks === 0`). `setTimeout` is not compositor-gated
there — `drawElementService.ts` ~341 describes its 250 ms paint fallback as
burning on every BeginFrame frame, which requires the timer to fire — so a
timer ceiling is a sound escape.
`awaitCanvasPaint` now resolves `"painted" | "timeout"` with a 2000 ms ceiling
(the repo's own `BEGINFRAME_PROBE_TIMEOUT_MS`; ~20x a real two-frame paint,
far short of the ~180 s CDP `protocolTimeout` a hang would burn). The race is
per host, so one stalled compositor no longer delays hosts that would have
painted; a timed-out host is skipped (no capture, no guessed pixels) and
reported loudly. The report is not suppressed in engine mode: `frameCapture.ts`
~2708 skips `__hf_page_composite_resolve` in `drawelement`/`beginframe` modes,
so there is no authoritative second attempt to defer to there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(ci): re-pin test-classification.mjs hash after registering vfxDeterminism.test.ts
An earlier commit added vfxDeterminism.test.ts to INTEGRATION_TEST_FILES;
check-test-reachability.mjs pins a sha256 of that file to force a conscious
review whenever it changes. Reviewed: runner[1]'s glob already covers the
file structurally, the classification move (unit -> integration) is correct
for a Chrome-launching test, and the pinned hash is now current.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* chore(core): regenerate position-edits-render-inline.ts for Linux esbuild output
check:position-edits-render (bun run build:position-edits-render && git diff
--exit-code) failed on both Linux CI runners (Test (runtime), Tests on
windows-latest: studio-core) but passed on macOS: esbuild's minifier named
two identically-scoped WeakMap/function bindings differently on Linux than
on the mac that last committed this artifact, and the divergence appeared
only on this branch (main's own current tip passes CI cleanly), so it is
tied to a change on this branch rather than a random flake, even though it
is not attributable to any specific line of that change.
Confirmed stable across two independent Linux CI runs (byte-identical diff
both times) and reconstructed directly from that diff rather than guessed:
the committed content, byte length (3817), and 3/3 existing artifact tests
all match. A future contributor rebuilding on macOS will see this same
diff again in the other direction until the build script's cross-platform
determinism is fixed at the esbuild-invocation level, which is out of this
PR's scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(catalog): regenerating the texture-mask page no longer duplicates its Texture masks section
The page generator carries forward any section whose heading it does not
own, and it emits "## Texture masks" without owning it. Every run kept the
old copy and appended a new one; main's page had three.
* chore(catalog): drop the two duplicate Texture masks sections from the texture-mask page
* chore(catalog): run oxfmt on the texture-masks regression test
* feat(studio): audio meter faders for groups and master
* fix(studio): meter's mid band uses amber-500, the theme's actual token
yellow-500 was reset to nothing by palette-reset.css (--color-*: initial)
and never redefined in theme.css, so the mask never revealed any color
for -6 to -3dB; the token gate test already catches this and was red.
* refactor(studio): rename YELLOW_AT to AMBER_AT to match the amber token
* fix(studio): fader nudge and aria-valuenow match the visual dB position
* refactor(studio): use core StereoLevel for meter channel shape
* fix(core): emit levelTap in the packed core build
* fix(studio): name the preview fader Monitor and share clampNumber
* fix(studio): split the meter rAF tick and cover its branches
* style(studio): oxfmt AudioMeterStrip tests
* fix(studio): preserve authored gain in audio meter faders
* fix(studio): make timeline audio meters opt-in
* feat(studio): typed audio values, clip fades, audible video drops
Audio rows in the media inspector (Volume, Speed, Media start) take a
typed value: click the readout, type "-6 dB", "1.5x" or "2.5s", Enter.
Speed presets are hidden on <audio> clips.
Clip-edge fades: data-fade-in / data-fade-out in seconds, honoured by the
preview runtime (per-tick gain), the render mixer (afade after volume) and
Studio (Fade in / Fade out rows, and drag dots at the top corners of audio
clips and audible videos). Linear on both sides so preview and render
agree; fades that outrun the clip scale to meet inside it.
Dropped videos with an audio stream now land with data-has-audio="true"
instead of muted, so the mixer keeps their sound. The media metadata
endpoint reports hasAudio from the ffprobe call it already makes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(audio): one owner for fades, dB, and afade-after-volume
Illegal fade values become no-fade at fadeGain; typed inspector input
still returns null so the readout stays red. Master and group mixes
share one filter chain with volume then afade. Fade-out stays anchored
to the trimmed clip end.
* fix(studio): share audio helpers and bound typed media values
* fix(studio): reserve sibling fade duration when editing
---------
Co-authored-by: Jake Moran <jakemoran2011@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* feat(cli): move media-use engine into the CLI
* fix(cli): replace media-use compatibility asset
* fix(cli): replace moved media-use tree
* fix(cli): copy moved media-use files cleanly
* fix(cli): copy media-use engine files individually
* fix(cli): keep audio helper compatibility import
* fix(cli): clear copied skill destinations
* test(cli): preserve moved media-use test paths
* fix(cli): preserve media-use script compatibility imports
* fix(cli): preserve media-use telemetry shim
* fix(cli): resolve packaged media-use LUT assets
* fix(cli): resolve packaged bundled SFX assets
* chore(skills): regenerate media-use manifest
* fix(cli): preserve media-use parity coverage
* fix(cli): cover relocated media-use tests
* chore(skills): regenerate media-use manifest
* fix(cli): format bundled media provider
* fix(ci): run relocated media-use tests with node
* fix(ci): preserve fallow script exemptions
* fix(ci): scope media-use fallow exemption
* refactor(cli): trim media-use essay comments
* feat(cli): index media-use assets for catalog search
* feat(catalog): commit media-use search vectors
* chore(catalog): track media vector metadata
* fix(catalog): validate media vector row order
* fix(catalog): validate media artifact freshness
* fix(catalog): cover media metadata and credits
* fix(cli): reject malformed media rows
* fix(catalog): compare complete media rows
* feat(cli): route media search through media-use
* refactor(cli): trim relocated media-use comments
* fix(cli): make media search runtime-safe
* fix(cli): preserve sfx fallback diagnostics
* fix(cli): probe sfx fallback provider explicitly
* chore(skills): regenerate manifest
* refactor(cli): keep typed media ranker source
* fix(cli): align media search checks with local-first flow
* fix(cli): scope vector audit exceptions
* fix(cli): ship resolver registry beside skill
* fix(cli): keep media-use skill scripts standalone
* test(cli): cover local vector validation
* test(cli): correct media vector fixtures
* fix(cli): prefer exact media keys
* fix(cli): stem exact media keys
* test(cli): skip media lint for snippets without media
* fix(cli): align skills test discovery
Keep the CI skills job's bash discovery as the single source of truth so its empty-list guard covers the exact files Node executes. Make hasMediaVectors private because it has no external callers and needs no Fallow suppression.
* fix(cli): close media-use review gaps
Prioritize literal ids before stemmed ids and cover the collision alongside the plural regression. Add command seam tests for engine discovery, actionable failure, and argv passthrough. Add a deterministic parity check for standalone media-use copies, with documented allowlists for the standalone media-fetch shim and self-contained npx resolver. Keep the bash-discovered skills test list as the CI source of truth.
* fix(cli): lower parity check complexity
Split missing-copy and unallowlisted-drift checks so the parity guard keeps its behavior without a threshold CRAP score.
* fix(cli): close media-use review seams
Run the parity test in the skills job so skills-only edits exercise the same check. Export the verb mapping and inject command dispatch for direct coverage. Keep missing-copy diagnostics separate from drift reads.
* feat(studio): audio level meters at the timeline's right edge
* test(core): cover metering started before the audio context finishes init
* fix(studio): keep meter follow after a dead preview and start bars at silence
* fix(studio): keep the 0 dB peak tick inside the meter
* fix(studio): keep the 0 dB peak tick visible without calc()
* fix(core): tap meters on the program bus, mute on a monitor gain
* style(core): wrap applyMasterGain to satisfy oxfmt
* feat(studio): edit a clip's speed ramp with a rate lane and presets
* fix(studio): keep the live preview attribute in sync with what gets persisted
* fix(cli): snapshot picks the source frame through a rate lane and up to 10x
hyperframes snapshot clamped the constant playback rate at 5 and ignored a
rate lane in data-automation, so a sped-up or ramped clip showed the wrong
frame. It now reads the rate through the runtime's own reader and maps time
with the shared source-time function.
* docs(skills): teach speed ramps as a rate lane in one reference page
Adds the speed-ramps reference, corrects the 0.1 to 5 rate range to 0.1 to 10
and the 'speed ramps are not supported' lines in the skills, and regenerates
the skills manifest.
* docs(skills): say what fixes a ramped clip's length and how the render approximates pitch
* fix(studio): hide the speed preset until the clip has a known duration
A preset on a clip with no data-duration was stretched over a made-up one
second. The panel now offers presets only when the duration is known.
* docs(skills): fix the last stale ramp line in the editing recipes and the slice-cap wording
* feat(core): add an inlineAssets option to skip base64 asset inlining
A same-origin caller can now keep every font/image src as its authored path, not a data: URL.
Default true, unchanged for every existing caller.
* fix(core): also stop inlineAssets: false from inlining LUTs
The same bug (a path that reads back as data: URL) applied to LUT references too.
Composes with the pre-existing inlineColorGradingLuts flag; default is unchanged.
* feat(core): a composition with no authored length takes it from its clips
A new resolver in @hyperframes/parsers returns a composition length as authored,
derived from its latest clip end, or unresolved, and counts clips whose length is
still pending. The runtime uses it in its two existing max-end loops with identical
results, and as a last resort only when no timeline, floor or caller length exists,
so a composition that has a length today keeps it. The derived source is published
on window.__hf.durationSource for render telemetry.
Lint reports a root that has timed clips but no data-duration as a warning that says
where to author it, and still errors when there is nothing to derive from.
* refactor(core): resolve the runtime duration floor in one call
The declared duration and sub-composition ends were resolved separately and
maxed by hand; one resolver call over both gives the same latest end.
* fix(core): a derived composition length waits for every clip length and reports honestly
Review follow-up. The runtime returns a content-derived length only when no clip is
still pending, so a renderer that reads duration once cannot lock in a short one.
window.__hf.durationSource is cleared whenever a timeline or floor supplies the length,
and the diagnostic fires only for a derived length. Lint says "at least Ns", counts
reference-start clips as known only at runtime, and ignores a data-end before data-start.
* fix(core): a sub-composition with no known length counts as pending
A host whose own timeline is not registered yet no longer lets the content-derived length
show early. Tests pin the pending host and that the diagnostic fires only for a derived length.
* fix(core): a loaded Lottie with no registered animation keeps the length pending
Lottie registers from author scripts with no DOM marker, and nothing re-derives the length
after it registers. A composition with clips and a loaded Lottie library therefore stays at
zero, as before, instead of locking in the clips length and cutting the animation short.
* test(core): a registered Lottie supplies the length and the clips length is not used
Pins the other side of the pending Lottie rule. Once an animation reports a length it enters the
duration floor, so the content-derived fallback is never consulted.
* test(core): the declared and DotLottie pending branches are covered
* fix(runtime): a finished film rests on its last frame instead of a blank one
The clip-visibility check was half-open (start <= t < end), so a clip whose
authored end reached the composition's total duration hid at exactly that
instant. The player parks there when playback ends, so a finished film
went blank instead of holding its last frame, in both the published
player and Studio's scrub-to-end.
Extracts the shared half-open comparison used in seven places into
isInClipWindow (packages/core/src/runtime/clipWindow.ts) and adds
isClipVisibleAt, which additionally treats a clip reaching the
composition's terminal instant as visible. Only the DOM-visibility gate
uses the terminal rule; playback and media-activation windows keep the
plain half-open check, so two back-to-back clips still never both show
at their shared boundary and audio never re-activates past the end.
* fix(runtime): hold a terminal video on its last frame at the composition end
* refactor(player): route the proxy clip-window checks through the shared predicate
Makes the composition duration required on syncRuntimeMedia so a caller
cannot drop the terminal rule, exposes the predicate as a core subpath,
and adds a nested-timeline float-noise test.
* build(core): declare the runtime/clip-window subpath in the package-subpaths descriptor
* test(runtime): a speed-ramped video that runs to the composition end holds its last frame
Covers the composition of the terminal hold with a rate lane: at a time
past the clip end the video sits at the source time of its own end.
A new resolver in @hyperframes/parsers decides how long a video, audio or image
occupies the timeline. The core runtime start resolver, clip manifest and clip
tree now read length through it. An image with data-start and no data-duration
gets the dropped-image default of 3 seconds; a bare image stays a static layer.
Video and audio take their length from the file until an authored duration trims it.
The playback rate bound (0.1 to 10) now lives in parsers, and core re-exports it.
A rate lane keeps its own arithmetic in core.
The lint rule that asked images for data-duration is removed, and the docs no
longer say an image requires it.
* feat(engine): render speed ramps with the same source-time mapping as the preview
The frame extractor, frame lookup table, coverage check, compile-time duration and audio mixer
read a clip's rate lane through sourceTimeAt, so a rendered frame at t shows the source frame
the preview shows. Audio bakes a ramp as consecutive source slices stretched by their mean
rate, keeping pitch. The compile-time attribute reader now handles single-quoted and
entity-encoded JSON attributes. Adds a speed-ramp regression fixture.
* test(producer): regenerate the speed-ramp golden audio with the current engine
The stored baseline predates the per-slice audio fix, so its audio drifted 1 to 3 percent in
pitch from the expected source-time track and the CI render correlated at 0.61. Rendered in the
CI test image, the new baseline tracks the expected pitch to 0.1 percent median, and the compare
now reports visual 0 failed frames and audio correlation 1.000.
* refactor(core): drop data-preserve-pitch, pitch is preserved by construction
The engine always time-stretches audio with atempo, so an opt-out read only by
the preview made preview and render disagree. Remove the attribute and its
reader so both paths keep pitch.
* feat(core): speed ramps as a rate lane in data-automation
A clip can carry a rate lane in data-automation. sourceTimeAt integrates it to source time and is the one mapping the runtime uses for media seeking, playback rate, natural duration, child timelines and the audio-master clock. Rate is clamped to 0.1-10 in one place, pitch is preserved unless data-preserve-pitch is false, and ramped clips play through the media element. Lint warns on a rate lane outside video and audio.
* fix(core): format the speed-ramp files and expect the unified rate clamp in the engine tests
* test(engine): read the playback-rate clamp from core instead of a literal
* feat(cli): hyperframes timeline prints a project's tracks and clips
* docs(cli): list the timeline command
* feat(cli): timeline command reports unreadable lanes; add skill docs
* fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes
* fix(cli): timeline resolves symlinks before the project containment check
* fix(cli): timeline accepts project folders whose name starts with two dots
* refactor(cli): timeline and compositions share the engine's start resolver
* docs(skills): point agents at the timeline command where they decide how to read a project
* fix(cli): timeline prints absolute main-timeline times and the owning file for nested clips
Nested rows carry absStart/absEnd on the main timeline and their owning
file, alongside existing local start/end. Text prints absolute time first,
local time in parentheses. Adds jq/node one-liners for common queries,
tested against a fixture.
* fix(cli): timeline places nested media at the runtime start and tests its query one-liners
A nested media clip with a negative literal data-start was reported later than it plays,
because the raw start was clamped before the host offset was added. The skill's jq and node
one-liners now run against the fixture in the test suite.
* fix(cli): timeline clamps a negative media start to 0 when its host starts at 0
The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the
timeline command now does the same.
* refactor(core): one media-start rule shared by the runtime and the timeline command
The runtime and the CLI each carried a copy of the rule for when a nested media clip's
start takes the host offset, and the copies had drifted. Both now call
resolveMediaStartSeconds.
* test(cli): timeline fixture for a media start written as an expression
parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the
two callers cannot disagree on what counts as a literal; this pins the expression case.
* docs(skills): the agent presents a tailor-made storyboard page instead of a Studio board
Adds the storyboard recipe (decisions first, beat fields, a self-contained
storyboard.html contact sheet, a review round) distilled from the launch
films, and rewrites the review loop, brief contract, storyboard format and
workflow steps that still described the deleted board and comments file.
* docs(skills): reconcile the sketch pass with the beautiful storyboard sheet
Review found: the recipe's storyboard.html cell spec (real fonts, real
colors) contradicted review-loop's own sketch definition (wireframe, no
brand treatment). The sketch is static, not unstyled; only motion is
deferred to the build pass. Also fixes a frame-packet boundary note, a
craft-device citation, and a provenance overclaim.
* refactor(studio): remove the storyboard view and its Storyboard/Preview switch
* refactor(studio): fold the thumbnail output branches left after the storyboard mode went
* docs(studio): drop leftover storyboard references after the view was removed
* ci: allow the storyboard view deletions in the no-deletions guard
* docs(studio): wrap an over-long comment line in the caption overlay helper
* style(studio): reindent App.tsx after the view-mode wrapper was removed
* fix(core): script coalescing no longer moves an inline script past a src script
* test(core): pin how the script coalescers group inline runs
* test(core): simplify the pinned-script grouping test
* fix(player): wait for a cross-origin composition's assets before playing
The player's readiness gate scans the iframe document, which is null when the
composition is embedded from another origin, so it released playback at once
and the first frames could play with images still loading.
The runtime now runs the same readiness scan inside the iframe and posts
assets-ready; its timeline message reports whether assets have settled so an
older runtime, which never posts it, is not waited on. Also declares the
animation-frame abort handler before a host whose requestAnimationFrame calls
back synchronously can reach it.
* fix(player): keep a ready cross-origin player ready across a late iframe load
The iframe load event reset readiness for any document the player cannot
read. The runtime posts its timeline before load and a paused runtime never
posts it again, so a load arriving after the assets settled left the player
not ready. A ready cross-origin player already holds this document's
handshake, because every navigation clears readiness first.
* fix(core): stop RuntimeReadinessWindow widening Window.__hf so the runtime typecheck passes
* fix(core): a torn-down runtime no longer posts assets-ready
The shared composition-readiness gate only listened for future canplay/error
events on pending media, so an asset whose error already fired before the
gate started listening was never seen -- a DOM error event doesn't refire.
That left the gate burning its full 8s timeout on every load. Measured on a
production composition with 85 duplicate small audio elements pointing at
missing files: 8.68s to 0.83s with this fix, no timeout warning.
* feat(core): hold render-ready on declared compute and a build-ready hold, not just duration
Adds the compute and paint-and-idle inputs to the composition readiness gate,
plus window.__hf.buildReady: a promise registry a piece registers when it has
setup work no adapter can see (mesh building, shader compiles). The runtime
holds render-ready on it and the player shows its loading state instead of a
blank frame while it resolves.
* refactor(core): dedupe the paint-and-idle readiness test setup
Simplify pass on 23810da1b: fallow flagged the two paintAndIdleReadinessInput
tests as a 12-line clone; extracted the shared doc/tracking setup.
* fix(core): stop the readiness settled-tracker hanging on 2+ pending promises
Promise.all allocates a new promise every call, so comparing ITS identity
across repeat polls (isAdapterReadinessSettled, isBuildReadinessSettled) never
observes settled once 2+ promises are combined: every poll rebuilds a new
combined promise, re-arms, and republishes forever without the caller ever
seeing true. Reproduced with two already-resolved window.__hf.buildReady
entries, which is the exact multi-piece usage the new authoring doc describes.
Fixes both trackers at once with a shared createSettledTracker that compares
the source promise array by element instead of the Promise.all wrapper.
* fix(core): cancel readiness inputs once settleCompositionReadiness settles
computeReadinessInput's poll timer and paintAndIdleReadinessInput's rAF chain kept
running after the shared timeout won the race, since nothing told the losing
inputs to stop. One AbortSignal now threads through all three inputs and is
aborted the instant the race settles.
* docs(core): fix a stale sync-callback claim in settleCompositionReadiness
paintAndIdleReadinessInput always has a frame to wait on for any real
document, so the sync fast path this docstring described is no longer
reachable with the default inputs except for a viewless document.
Found by the simplify pass on this branch.
* fix(core): clear a stale buildReady entry on runtime teardown
A never-resolved window.__hf.buildReady promise from a torn-down
composition survived into the next init in the same window (window.__hf
is never reset), permanently blocking render-ready for whatever loaded
next -- the exact black-screen class this branch fixes, reproduced
through its own new mechanism. Found by the independent review.
Also fixes a stale hyperframes-player.ts docstring (media/images/fonts
had not been updated to compute + paint-and-idle) and documents the
buildReady key-collision hazard in three.md.
* fix(core): skip the compute readiness wait when no runtime ever ran
computeReadinessInput polled window.__renderReady for the full shared
timeout on any same-origin document with no HyperFrames runtime
injected (a probe-only composition using native video/duration, or a
blank test iframe) — nothing was ever going to set that flag. Checking
window.__hf first (the same signal composition-probe.ts already treats
as "has a runtime") lets it return null instead of waiting.
* fix(player): debounce the loading overlay and diagnose compute timeouts
paintAndIdleReadinessInput always has a frame to wait on for an
attached iframe, so settleCompositionReadiness's synchronous-settle
path is unreachable in practice now — the loading overlay showed on
every single Play, even ones with nothing genuinely pending. Debounce
the overlay itself instead: a wait that resolves fast never shows it.
Also names compute (window.__renderReady) in the stuck-assets timeout
diagnostic, which previously only re-scanned media, images and fonts.
* fix(engine): name a stuck buildReady key in the zero-duration diagnostic
A build promise a composition registers on window.__hf.buildReady that
never resolves left the export path's zero-duration timeout naming
nothing useful — hasHf/hasSeek/duration all looked fine, since the
runtime was genuinely present and just waiting on that one promise.
Probes buildReady's keys for pending ones and names them in the hint.
* fix(engine): stop the buildReady diagnostic from crashing on a rejection
A microtask race against a bare resolved promise rejected the whole
diagnostic expression on any rejected buildReady entry (the runtime's
own createSettledTracker rejects deliberately) and mis-sorted a settled
non-native thenable as pending, since its extra adoption tick loses the
race. Both measured directly, not inferred.
Settle each key onto its own record past one macrotask instead: no
throw, and pending/resolved/rejected are distinguished correctly. A
rejected key gets its own hint, separate from a merely pending one.
* fix(player): report a backgrounded document instead of overclaiming a paint bound
MAX_PAINT_WAIT_MS only bounds a document that keeps producing frames; one
that stops entirely (backgrounded tab suspends rAF, or fewer than 3 frames
ever fire) still rides the full shared 8s timeout, same as before this
input existed. Corrected the comments that claimed otherwise.
That gap is exactly why the stuck-assets diagnostic left paint-and-idle
unreported: there was no accurate way to say it might be the cause. Added
documentHidden (doc.hidden) to the diagnostic instead, since a backgrounded
tab is the reachable, directly observable case.
* refactor(engine): simplify the buildReady diagnostic rewrite
Skip the macrotask wait entirely when a composition registers no
buildReady keys — the common case, since it's opt-in. Nothing to settle
means nothing to wait on.
Collapse buildZeroDurationDiagnostic's inline parameter type into the
existing HfDiagnostic interface instead of maintaining two copies of
the same shape.
* docs(engine): note the setTimeout(0)-thenable gap the review found
An independent adversarial review, executing hostile inputs rather than
tracing, found the macrotask wait can still misreport a thenable that
resolves via its own internal setTimeout(0), since the diagnostic's own
timer is registered before the thenable's. Bounded and self-correcting
(only feeds the message text, and the diagnostic re-runs every ~1s), so
documenting the boundary rather than chasing an unbounded fix.
* feat(player): wait for a composition's media, images and fonts before playing
The player used to fire ready/autoplay the moment the timeline resolved,
even if the composition's video, images or fonts hadn't loaded yet -
visible as a flash of missing content or a frozen first frame. It now
waits for those assets (readyState >= HAVE_FUTURE_DATA for media, decoded
images, document.fonts.ready) before play() proceeds, showing its own
loading overlay (reusing the shader-transition loading panel) while it
waits, bounded by an 8s timeout that plays anyway and logs which assets
were still stuck.
Settles synchronously with no overlay when nothing needs waiting (already
loaded, or a cross-origin composition the player has no DOM access to
inspect), so today's immediate-autoplay behavior is unchanged for the
common case. A new assetsReady getter and assetsready event mirror the
gate for hosts that want to observe it.
* chore(player): drop internal decision-ref from a public-repo comment
An internal tracking id leaked into a code comment and a test name in the
previous commit. This repo is public; internal references don't belong here.
* fix(player): stop a stale asset-wait from settling a newer composition
An independent review found that a composition swap mid-wait (a new src or
srcdoc arriving while the previous composition's media/images/fonts were
still loading) had no way to invalidate the old wait. If the old wait's
promise resolved after the swap, it could mark the NEW composition ready
and even play it, while the new composition's own assets were still
pending - defeating the point of waiting in the first place. The same gap
let a late-resolving wait flip a disconnected player's paused state back to
false and fire a spurious play event after removal.
Fixed both with one generation counter: every new wait captures the current
generation, disconnect and reload each bump it, and a wait's eventual
settle no-ops if its generation is no longer current.
Also fixed: the loading overlay's aria-label ("Loading assets") never got
restored once the asset wait was done, so a later shader-transition overlay
on the same player kept misannouncing itself to assistive tech.
* fix(player): invalidate the asset wait from every navigation path, not two
The generation-counter fix only bumped at two of the six places that set
_ready = false ahead of a navigation, reload, or disconnect - src/srcdoc
attribute changes, a sandbox-origin policy reload, and a shader-options
reload all started a new navigation without invalidating the previous
wait, leaving the same race open through those paths.
One _invalidateAssetsWait() helper now owns the reset (assetsReady,
pendingPlay, generation, the loading attribute, hiding the overlay) and
every one of the six sites calls it before starting whatever comes next.
* fix(player): gate play on real composition readiness, not just duration
Add a shared composition-readiness gate in packages/core, exported as the
@hyperframes/core/composition-readiness subpath. It resolves once every
declared input settles (media for now: video/audio/image/font loads), or
after a timeout, and calls back synchronously when nothing is pending so a
caller that plays immediately after sees the decision already applied.
The player element now queues an early play() call instead of dropping it
when called before the runtime is ready, and both of Studio's readiness
call sites (the direct-adapter path and the message-based fallback) go
through one store action, requestTimelineReady, instead of duplicating a
generation-counter-and-settle pattern across two hook files.
* fix(player): stop a queued early play() and a project switch from double-firing readiness
An independent review of the readiness gate found two real races:
play() queued before the runtime was ready re-ran its full body once ready,
dispatching the public "play" event twice for one user action. It now
dispatches "play" only on the call that actually starts playback.
Studio's requestTimelineReady generation guard was never invalidated by a
project switch or a full timeline reset, so a slow-loading project's
in-flight wait could resolve after the switch and mark the new, unchecked
project ready. Resetting the timeline now bumps the generation, and setting
timelineReady directly does too.
* fix(player): cancel a queued play on pause or seek during the asset wait
A play() queued while assets buffer (_pendingPlay) used to fire once they
settled even after an explicit pause or seek during the wait. pause() and
seek() now clear the flag, so a user pause/seek wins.