* docs(changelog): weekly digest 2026-09-21-2026-09-28
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs(changelog): embed the Sep 21-28 weekly video
Adds the 50.4s square changelog video for the Sep 21-28 digest,
served from the static CDN.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* perf(studio-server): light entries for safe paths, hf-id stamping and project history
* docs(studio-server): list the light entries a host can import
* perf(studio): light ui and player entries for a host's first screen
* perf(studio): the ui entry's logo no longer loads the studio header
* test(studio): refuse only packages studio itself depends on
* test(studio): the light entries also refuse the hf-ids parser
* feat(cli): tag telemetry and feedback with the app that launched the CLI
* fix(cli): keep the client tag to a short slug and correct the telemetry docs
* test(cli): read the delivered batch through the shared helper
* refactor(cli): let the tag pattern alone bound the client tag's length
* docs(cli): describe the client tag generically as the launching app's tag
* style(cli): format the client tag test cases
* test(cli): use a neutral example value for the client tag
* chore(skills): regenerate the skills manifest for the telemetry reference
* fix(release): name Release once in a changelog entry's tags
* fix(release): the release tag appears once in any case; four past entries lose the duplicate
* docs(cli): render help says when page-side compositing is actually used
* docs(cli): page-side compositing help names webm and mov and the fast-capture flag
* docs(cli): say plainly that gif keeps page-side compositing
* fix(cli): commands exit without waiting on update checks or telemetry
The update and skills checks run in a detached child that refreshes the
caches for the next run; the parent only reads the caches. finalizeCli no
longer awaits the telemetry flush: the exit handler flushSync() delivers
what is queued. The events beacon, feedback and figma error paths stop
awaiting flush() too.
* fix(cli): retry a failed background check at most once an hour
Before starting the background worker, the CLI stamps a per-check attempt
time and skips a check it attempted within the hour, so a dead network no
longer starts a worker on every command. The stale-pin notice writes onto a
fresh read, detached spawns set windowsHide, and a test ties the worker path
to its build entry.
* fix(cli): a failed-check stamp dated in the future no longer silences update checks
* feat(cli): download Parakeet with models install parakeet and transcribe with it
`hyperframes models install parakeet` installs the sherpa-onnx runtime from
npm and the Parakeet TDT 0.6B v3 int8 model from Hugging Face, pinned to a
revision. Every file is checked against its pinned size and sha256 under a
temp name before it is renamed into place. Re-running it is a no-op. On
Linux with glibc older than 2.32 it refuses up front.
`transcribe --engine auto|parakeet` now prefers that engine when it is
installed, then an installed parakeet-mlx runner, then whisper. Audio is cut
into about 60 s windows at the quietest 100 ms near each cut, and decoding
runs in a child process. If the child cannot load the runtime or crashes,
transcribe falls back with one stderr line saying why. The JSON output and
transcript.json shape are unchanged.
* fix(skills): report the real word count and engine from transcribe fallbacks
The media-use transcribe script read `words` from the CLI's transcript,
which is a flat array, so its whisper path always reported an undefined
word count. The embedded-captions script labelled every CLI transcript
whisper.cpp even when `--engine auto` ran Parakeet; it now uses the engine
the CLI reports.
* fix(cli): pin the sherpa-onnx native package to the runtime's exact version
* test(cli): type the sherpa install test doubles
* fix(cli): transcribe audio with no speech as 0 words instead of failing
Parakeet writes an empty word list for a music bed, a silent intro or a
very short clip, and loading it failed with "Unrecognized JSON transcript
format". An empty list is now read as a transcript with no words, as
whisper's empty output already is. A non-empty list still has to look like
words.
* fix(cli): re-decode a Parakeet window that skipped the start of its speech
At some window lengths the model silently drops the first clause: on a
looped speech clip, 4 of 167 lengths between 55 and 60 s, and a 61.91 s
window, lost their first 9 words. A window with a token gap of at least 1 s
that is as loud as speech is decoded again with 0.5 s of leading silence,
and the result with more tokens wins. Clean windows are unchanged.
Decoding with 1.5 s of overlap before each cut was measured too and was
worse on 573 s of audio (2.62% WER against 0.98%), so it is not used.
Empty audio no longer makes a zero-length window, which aborted the
decoder.
* fix(cli): stop Parakeet on Ctrl-C and name the repair when its install breaks
- Ctrl-C during a Parakeet transcribe now stops with exit 130 instead of
starting whisper, and a SIGTERM to the CLI also stops its decode child.
- Ctrl-C during models install parakeet aborts the download and the npm
install, keeps no partial file, and exits 130. A staging dir left by a
killed install is swept by the next run.
- When Parakeet fails under --engine auto, the warning leads with the
Parakeet error and names hyperframes models install parakeet. If the
fallback fails too, the Parakeet error is reported rather than
whisper_unavailable, so --optional no longer hides a broken install.
- An explicit --engine parakeet no longer falls back: it fails with the
Parakeet error and the repair command.
- The install refuses up front on systems with no native package
(naming the six supported ones) and on musl, instead of a raw npm error.
- 24-bit and float WAV go through ffmpeg, since sherpa-onnx reads only
16-bit PCM. The decode timeout scales with the audio length.
* docs(cli): point Parakeet setup at models install parakeet
The transcribe docs and the media-use references named parakeet-mlx as the
only way to get Parakeet. They now name hyperframes models install
parakeet, and the CLI reference gets a models section.
* test(cli): cover the npm cancel signal and decode timeout, and fix a doubled period
The Parakeet failure line printed "failed.. To repair" when the decoder's
own message ended in a period. Tests now also pin that Ctrl-C reaches the
npm install and that the decode timeout scales with the audio length.
* fix(cli): report unreadable audio as an input error, not a Parakeet failure
Prepare the WAV once before the Parakeet fallback and hand the same file to whisper,
so ffmpeg runs once. Ctrl-C while ffmpeg runs now ends as a cancel (exit 130)
instead of starting whisper.
* fix(cli): recover a clause Parakeet drops after a pause by decoding the gap alone
Each loud gap without tokens is decoded on its own with 0.5 s either side, and only
its new tokens are spliced in. The padded whole-window retry stays as the fallback.
A window with no tokens at all is no longer decoded twice.
* perf(cli): size the decode timeout from the WAV header instead of reading the file
* docs(cli): say a broken Parakeet install still fails transcribe --optional
* fix(cli): treat Ctrl-C during audio preparation as a cancel and drop the partial WAV
ffmpeg traps SIGINT and exits 255 rather than dying by the signal, so that status now
counts as a cancel unless the timeout fired. A failed or stopped ffmpeg removes the
temp WAV it was writing. The decode timeout now sizes a prepared WAV from its file
size, so a large header can no longer hide its length.
* fix(cli): splice a re-decoded gap by whole words so its neighbours are never doubled
The gap decode splits edge words into other tokens than the window decode did. Match
neighbours as whole words within 0.3 s of each gap edge, add only whole words that start
inside the gap, and decode the gap behind 0.5 s of silence like the padded retry.
* fix(cli): count a cancel only when ffmpeg says it received a signal
ffmpeg 7 and later also exit 255 when a file is not permitted (EPERM), so exit 255
alone read a permission error as a cancel. ffmpeg's stderr is now captured, a cancel
needs its received-signal line, and any other failure reports ffmpeg's last line.
The output path is passed to the shared runner explicitly.
* fix(cli): keep a spliced gap word that has no leading space
The filter dropped such a word; it is now kept and given its word boundary.
* refactor(cli): trim three long comments in the Parakeet decoder and installer
* fix(cli): repair a Parakeet runtime missing its native binary, and pick Parakeet by language
The runtime dir is per platform and arch. Installed now needs the runtime
manifest and the native sherpa-onnx.node; a half-installed dir is removed
before npm runs, since install() keeps any dir holding the runtime manifest.
The decode worker flattens a multi-line loader error onto its prefixed line
and stops between windows once orphaned. transcribe opens its cancellation
scope before audio prep, so Ctrl-C under --json cleans up. auto uses
Parakeet only for its 25 languages and only where the host can run it;
--engine parakeet with another language fails.
* fix(cli): sweep reused-pid staging, name failed model downloads, keep ffmpeg's cause
sweepStaleStaging also removes a staging dir older than 6 hours whose pid
is alive, since containers reuse pids. A failed model download names the
file, the host and a retry. runFfmpeg keeps ffmpeg's stderr on its error,
so transcribe shows "Output file does not contain any stream" for a video
without audio, and reads a cancel only from ffmpeg's own signal line.
* fix(skills): make media-use --engine whisper run whisper and report the engine that ran
The whisper path now passes --engine whisper when asked, or --engine auto,
and reads the engine from the CLI's --json line, so a CLI Parakeet run is
no longer labelled whisper. Docs state the language rule, the real repair
behaviour and the platforms Parakeet runs on.
* test(cli): pin that a long Parakeet decoder error is kept whole up to a bound
* fix(cli): report a broken Parakeet runtime with the repair instead of skipping it
* refactor(cli): name the staging age limit instead of commenting it
* fix(skills): let media-use --engine parakeet use the CLI Parakeet when parakeet-mlx is absent
* fix(cli): repair a Parakeet runtime that fails to load, and read Ctrl-C after whisper
models install parakeet now loads the runtime in a child process, the way
the decode worker does, and reinstalls it when that fails, so a missing
shared library, a corrupt binary or missing runtime JS is repaired. It
reports changed: false when the runtime loads. transcribe keeps its cheap
manifest check. After a failure, transcribe lets the event loop turn once
before reading the Ctrl-C scope, since a signal that landed during the
synchronous whisper fallback is only handled then.
* fix(cli): read Ctrl-C from the whisper child, and re-check the Parakeet runtime after a repair
A whisper fallback killed by SIGINT or SIGTERM is a cancel (exit 130), the
same rule decode and runFfmpeg use; the loop-turn wait is gone. The
runtime probe treats a child stopped by Ctrl-C as a cancel and keeps the
runtime; a reinstall is probed again and fails with the loader's error if
it still does not load. media-use keeps the CLI's last lines, stdout
included, when hyperframes transcribe fails.
* fix(cli): give the user-stop rule one owner, SIGHUP included, and end a cancelled install at 130
stoppedByCancelSignal in renderCancellation.ts uses the scope's own signal
list (SIGINT, SIGTERM, SIGHUP) and is false whenever Node stopped the child
(a code, killed, or a spawn error). decode, runFfmpeg, the runtime probe and
the whisper fallback all route through it, so a probe killed by SIGHUP keeps
the runtime. isWhisperTimeoutError now keys on ETIMEDOUT only, which Node
sets on execFileSync's own timeout, so a SIGTERM reaches the cancel rule.
A cancelled models install sets exit 130 and returns instead of failing, and
the probe says what went wrong (timed out, crashed, no output).
* refactor(cli): let the stop-rule helper name carry its meaning
* feat(plugins): ship portable agent bundles and installation guides
* fix(plugins): align new manifests with current release
* test(plugins): split caption fixture setup and cleanup
* test(plugins): pass render fixture path as an argument
* fix(studio): reload the preview only when a file it loaded changes
* test(studio): read the owner id from the new change-owner shape
* refactor(studio): drop a comment the parser name already says
* fix(studio): keep reloading until the preview is built, and count what its document names
* style(studio): format the reference pattern and name the kept reload
* fix(studio-server): scan preview references in linear time and refresh all thumbnails on a merged reload
* fix(studio): keep a waiting film change when a file the preview never loaded arrives behind it
* test(studio): name the preview ownership test for what it checks
* chore(ci): allow the watchIgnore removals and trim the new comments
* fix(studio-server): reload the preview when a folder holding a file it loaded moves in or out
* perf(studio-server): check a changed folder in one lookup and cover every reference form
* fix(studio-server): record read folders for a project at the filesystem root
* fix(studio-server): reload the preview when the file a symlinked asset points at changes
* fix(studio-server): resolve symlinks on every preview read and key projects by their real folder
* fix(studio-server): record every link a preview read passes through, resolved as the system does
* fix(cli): Studio's catalog install goes through add, so it records the item and honours paths
* fix(cli): Studio's catalog lists the project's registry and install paths survive symlinks
* refactor(cli): name the Studio catalog's registry option plainly
* feat(cli): registry_item_added says whether add or Studio installed the item
* fix(cli): dropping the same catalog block in Studio twice installs it again
* chore(cli): drop a comment that restates add's source option
* fix(cli): Studio's re-record finds lock entries by their resolved path
* test(cli): the symlinked-folder install test checks the resize happened
* fix(cli): Studio mounts the installed item's own file by the path add recorded
* fix(cli): Studio's install never hands back a file the project kept for its own edit
* fix(studio): avoid preview reloads for temporary and excluded files
* docs: capture public preview reload reproduction
* fix(studio-server): normalize watch paths in linear time
* fix(studio): register dynamically opened preview projects
* fix(cli): --yes in an unattended run no longer overrides a no saved by another process
* fix(cli): every settings writer takes the lock, and --yes needs a real terminal to reverse a no
* fix(cli): the thin-results download offer also needs a real terminal
* fix(cli): one owner-checked settings lock, and an unreadable settings file never counts as a yes
* fix(cli): settings lock removals go through a reaper, and a non-boolean answer counts as a no
* fix(cli): a settings lock that cannot be released no longer fails a write that landed
* fix(cli): a leftover settings lock is never taken over, and doctor names it
* fix(cli): a background update that cannot be recorded is not launched again every run
* fix(cli): every on-device install re-reads the saved answer first, and an unsaved yes installs nothing
* fix(cli): the thin-results offer stops when a yes cannot be saved
* fix(cli): only the consent-checked step installs the on-device runtime, and a leftover lock warns once
* fix(cli): the leftover-lock warning follows the attended-terminal rule
* feat(player): expose picture size, durationchange and a click-to-play opt-out
- compositionWidth/compositionHeight are public, ready carries them, and a
resize event fires when the picture size changes.
- durationchange fires when the duration changes after ready, on both the
runtime and the direct-timeline paths.
- disable-click-to-play leaves clicks on the picture to the host.
Nothing changes for a player that does not use the new attribute; ready's
detail only gains two fields.
* docs(player): say composition size, one doc line per size getter
* fix(player): apply scenes before ready fires, keep the zero-size warning, document the new API
* fix(player): fire a message's events after the whole message is applied
* fix(player): queue every player event behind the update that raised it
* fix(player): decide autoplay after ready listeners run, and restart a loop at time 0
* fix(player): keep raising events after a listener throws
* refactor(player): drop a comment the ordering test already pins
* fix(player): fire the rest of an update's events when a listener throws
* perf(player): let paused players check in once a second with low-power-idle
* refactor(player): trim low-power-idle comments and tidy its test
* fix(core): keep the fast paused heartbeat until a timeline is bound
* fix(core): wait for sub-compositions to bind before slowing the paused heartbeat
* docs(cli): explain how catalog search works and which model it downloads
* docs(cli): name the model catalog search was chosen against
* docs(cli): keep only live rules in the word-search header comment
* docs(cli): correct the hosted vector size in the local vector build comment
* feat(core): a composition's script can load its own files with __hyperframes.assetUrl
* test(core): a template composition inside a mounted file resolves assets from that file
* docs: assetUrl is for paths beside the composition, not project-root paths
* docs: say assetUrl misses a project-root path
The catalog already loads every item's manifest, preview included, but the
--json rows dropped it, so an app listing the catalog had no way to show an
item before add downloads it. Rows now carry preview when the item has one.
The attribute is in the HTML schema reference and enforced by the missing_data_no_timeline lint rule, but no page said what omitting it costs: a composition that never registers a GSAP timeline waits out player-ready-timeout (45 s by default) on every render.
That matters for triage because it is a fixed cost. "The render is slow" currently lists only causes that scale with the project - draft quality, media size, filter complexity - so an author whose renders are uniformly 45 s long reads the section, tunes the things it names, and sees no change.
- troubleshooting: add a symptom-keyed entry above "The render is slow", and point that section at it for the constant-penalty case.
- html-schema: give the attribute row the consequence and the lint code.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(player): a play() made while the composition loads still plays
The iframe's own load event reset the asset wait, and that reset also cleared the queued play,
so a play() between the first ready and load was dropped with no error. Play intent now leaves
the asset-wait reset: only pause, seek, disconnect and a new src or srcdoc clear it.
Also adds assets-loading-ui (player | none): none never shows the loading-assets card, while
the assets-loading attribute and the assetsready and painted events still fire.
* fix(player): switching the loading card off keeps a shader load on screen
* fix(player): the loading card and a shader load no longer hide each other
* 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>