Commit Graph
218 Commits
Author SHA1 Message Date
Brun3y 105984cac4 feat(cli): add IBM Bob runtime support (#3930) 2026-09-22 22:01:42 +00:00
e5689782c0 fix(cli): require non-empty feedback repro commands (#4263)
Co-authored-by: GPT-5.6 Sol <noreply@openai.com>
Co-authored-by: miguel.sierra <miguel.sierra@heygen.com>

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
2026-09-22 01:02:19 +00:00
Miguel Ángel 45f92e5572 fix(cli): a failed docker render names the container's last error (#4168) 2026-09-19 07:31:39 -04:00
Miguel Ángel e8fdfd0d65 fix(cli): install onnxruntime-node and google-genai on first use (#4156)
* fix(cli): install onnxruntime-node and google-genai on first use

Both carry deprecated transitive dependencies (boolean, node-domexception)
that showed up as npm warnings on every npx hyperframes install. They move
to devDependencies (types and the Intel-binding check still need them at
build time; the bundler externalizes them) and load through a new
on-demand loader that installs into the CLI's own cache dir the first
time a feature needs them, with one stderr line, no prompt, and a
manual-install command named in the error if the network is unavailable.

hyperframes doctor lists each as installed (with version) or
not-installed-yet.

* fix(cli): sweep stale install staging and bound network retries

* fix(cli): optional-package install only sweeps staging dirs whose process is dead

Staging dirs now carry a per-call suffix after the pid, and the sweep skips any dir
whose pid is alive, so concurrent first runs no longer delete each other's install.

The install error suggests the network only for network failures, and the manifest
path has one owner. install() takes an injectable npm runner so tests drive the real code.

* fix(cli): optional-package sweep also clears old-format staging dirs and tests a live foreign dir

The concurrent-callers test shared one pid, so it never exercised the sweep rule; a live foreign
staging dir test now does. Old-format dirs of a dead pid are swept, and a failed cleanup no
longer aborts a viable install.

* fix(cli): optional-package sweep names a stale dir it could not remove

A locked leftover is still skipped so the install proceeds, but the warning makes it visible.

* docs(cli): shorten the install docblock to the comment limit

Comment-only change.
2026-09-19 05:27:35 -04:00
Miguel Ángel 2db126d4c8 docs(skills): one owner doc for everyday edits and a hyperframes-studio skill (#4125)
* docs(skills): one owner doc for everyday edits and a hyperframes-studio skill

Agents got contradictory advice on volume fades and were blocked from splits
and copies by the Ask-agent guardrails.

- creator-editing-recipes.md is the single owner: adds align-to-event,
  copy-a-group and swap-media recipes and the one volume rule (use the lane)
- variables-and-media, data-attributes (skill and CLI copy), the gsap adapter
  and tracks-and-clips now defer to it
- Ask-agent guardrails allow the neighbour edits a split, retime, copy or swap
  requires and point at the owner doc
- new hyperframes-studio skill: sub-compositions for every scene, one caption
  track, one element kind per track, safe zones
- drift test lints every html example in the owner doc with the real linter
  and fails on any doc teaching a volume tween

* refactor(skills): drop unused tween allowlist from recipes drift test

* docs(skills): name the track kind attribute in the studio skill

* docs(skills): stop claiming lint rules that do not exist yet and fix track numbering

Review fixes:

- studio skill: drop the severity claim and the preview-overlay claim,
  group tracks by data-track-kind instead of numeric ranges that clashed
  with the recipes
- recipes: audio data-start is root time only in the root composition;
  scene-local inside a sub-composition file
- guardrails: timeline edits are exempt from the element-only and timing
  lines; the pointer names the skill reference, not a repo path
- montage doc defers to the volume lane; skill count is 21 everywhere
- drift test also scans the montage doc

* fix(skills): the ducking generator emits a volume lane and captions defer to the studio safe zones

- audio-duck.mjs prints a data-automation lane (clip-local times from the
  target data-start) instead of a timeline volume tween; --json adds the lane
- operations.md shows the generated lane; the drift test scans it
- embedded-captions safe-zone lines point at the studio skill, which owns
  the values

* chore(skills): regenerate the skills manifest after rebase

* docs(skills): remotion translation docs point volume ramps at the automation lane

* docs(skills): studio safe zones use the action-safe 90 and title-safe 80 boxes

* docs(skills): caption docs defer to the studio safe boxes

* chore(skills): regenerate the skills manifest after rebase

* fix(skills): studio skill names the safe margins; guardrail test matches the timeline-edit wording
2026-09-18 23:19:40 -04:00
Miguel Ángel 93921dccc8 fix(cli): stop the workspace build check test from depending on wall-clock mtimes (#4098) 2026-09-18 18:24:57 -04:00
Miguel Ángel 2a9912ec99 fix(cli): stop the cancellable-process test reading a pid file mid-write (#4080)
Same defect shape as the earlier preflight cancel test fix: the wait
condition only checked that the ready file existed, which goes true
as soon as the write opens (and truncates) it, before its content
lands. A read landing in that window parsed an empty string to 0.
The wait condition now also validates the parsed value is a real
positive pid before accepting it, retrying otherwise. Reproduced
with a busy-wait harness that hit the empty-file race in roughly
two of five attempts and confirmed clean after the fix.
2026-09-18 10:37:31 -04:00
Miguel Ángel de9206e0f8 fix(cli): detect a missing or stale studio build before it crashes (#4076)
* fix(cli): name the missing or stale workspace package before preview crashes

Running hyperframes preview from source with an unbuilt or stale
package previously crashed deep inside that package's own import
graph with a raw resolver error naming neither the package nor the
fix. Add a preflight that runs before the preview command's own
module import, so it also catches the crash that happens at CLI load
time before the studio dev server ever spawns.

* test(cli): cover the preview workspace-build gate wiring

Adds a test that exercises the real CLI dispatch path (mocked module
loaders, not source-text matching) to prove the preview command's
build-check runs before its module loads and that a failure exits
non-zero without double-printing. Also guards the exports-map reader
against a malformed package.json instead of crashing raw.

* fix(cli): correct why the audio-fx generated file is checked

The comment claimed core/src/index.ts imports both generated files
directly. Only runtime-inline.ts is; audio-fx-runtime-inline.ts is
reachable solely via core's own audio-fx-runtime export subpath,
which the engine package imports. The check itself was already
correct to require both, only the stated reason was wrong.
2026-09-18 09:27:39 -04:00
Miguel Ángel 096459e76d fix(cli): collapse startup lint output to a one-line summary (#4052)
* fix(cli): collapse startup lint output to a one-line summary

preview, publish, and render's non-abort lint check all dumped every
finding to the terminal before their real result line, drowning it in
noise unrelated to whether the project is in good shape. Full findings
already surface in Studio's own Lint badge; all three now print a
one-line summary by default, with --lint-verbose for the old full
output. One formatter (formatLintStartupMessage), three call sites.

* fix(cli): show lint findings in full on every abort path

Any path that calls presentRenderLintAbort now prints full findings
even without --lint-verbose, since the one-line collapse was hiding
the reason a render stopped.

* fix(cli): scope the lint summary pointer to what each command has open

The one-line lint summary always pointed at "the Lint badge in Studio",
even from render and publish where no Studio is running. It now points
at --lint-verbose for those, and preview additionally names the
hyperframes lint command since its own stdout is what an agent reads,
not the badge.

publish now forces full findings on its own entry-mismatch abort, the
same fix render already shipped, so the reason for the abort isn't
hidden behind a bare count.

* docs(cli): trim a doc comment past the 4-line limit
2026-09-18 04:34:53 -04:00
Aditya Poudyal e4b1832fad feat(cli): add hls output format (#4022) 2026-09-16 21:09:14 -04:00
heygengenesis[bot]andmiguel.sierra 08d48ab753 fix(cli): guard process snapshot capture (#3974)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-16 06:33:29 +00:00
heygengenesis[bot]andmiguel.sierra 5d2478c6fa fix: report HDR auto-promotion (#3985)
Report HDR auto-promotion consistently from the render media set, including remote media, and make its regression coverage portable across supported paths.

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-16 06:13:24 +00:00
Miguel Ángel 4c6fa9a790 fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref

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

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

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

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

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

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

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

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

Regression tests fail without each of the three changes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two corrections to the previous commit's reasoning:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reverts packages/cli to origin/main exactly.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

"Media the transport drives" now has one definition, shared by the media
cache and the paused-side probe, so neither can be narrower than the
other. Regression test: hosted media with no data-start started while
paused is stopped. Fails against the data-start-only scan.
2026-09-12 12:56:00 -04:00
miga-heygen 9c84277d28 fix(cli): let .hyperframesignore negation re-include hidden directories (#3859)
## What

`.hyperframesignore` negation rules (e.g. `!/.media/`) could never re-include a hidden (dot-prefixed) directory in the publish or cloud-render project archive, even though negation works correctly for every other kind of path.

## Why

`collectProjectFiles`'s walker called `shouldIgnoreSegment` first for every directory entry, and that check unconditionally excluded any name starting with `.` — before the project's ignore matcher (built from `DEFAULT_PROJECT_IGNORE` + `.hyperframesignore`, where negation is evaluated) ever ran on that path. A hidden directory was discarded at the walk step, so no negation rule downstream could ever reach it.

## How

- Moved the dot-prefix exclusion out of the hard `shouldIgnoreSegment` short-circuit and into the same ignore matcher that already parses `.hyperframesignore`, as a new default pattern (`.*`) in `DEFAULT_PROJECT_IGNORE`. Dot-prefixed paths are still excluded by default, but now via the same gitignore-style negation path as everything else, so a project's `.hyperframesignore` can override it.
- `shouldIgnoreSegment` is now reserved for the fixed, non-negotiable exclusions only (`.git`, `node_modules`, `dist`, `.next`, `coverage`, `.DS_Store`, `Thumbs.db`) — the set no `.hyperframesignore` rule should ever be able to reach.
- Added regression coverage for both directions: a `.hyperframesignore` negation re-including a hidden directory, and an unmatched hidden directory still being excluded by default (no behavior change for existing projects without an explicit negation rule).

## Testing

- `bunx vitest run packages/cli/src/utils/publishProject.test.ts` — 40/40 passing (2 new)
- `bunx vitest run packages/cli/src/commands/cloud/render.test.ts` — 10/10 passing (cloud render reuses the same archive builder)
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean, no changes needed

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-10 23:20:33 -04:00
Miguel Ángel e5a6023228 fix(skills): guard canonical stores from mirror aliases (#3714) 2026-09-08 18:10:44 +00:00
Miguel Ángel 00c91d6807 fix(check): allow missing caption overrides (#3716) 2026-09-08 18:09:35 +00:00
Xuanru LiandCursor e5d89f770f feat(check): flag connectors that point at nothing and stylesheets that leak into the frame (#3736)
* feat(lint): catch leftover marker heads and guessed marked shafts

Dash-draw on a marked path shows the arrowhead before the shaft exists.
A long marked path that misses every node in both user and screen space
is the same detach as the CTM-paste bug, without the counterfactual.

* feat(check): flag orphan connectors and unbalanced style tags

Catch a visible shaft while fewer than two nodes are on stage (enter-early /
exit-late), and extra </style> that dumps CSS onto the frame.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(check): judge connector_orphan by the shaft's own endpoints

The rule counted anchors stage-wide and skipped whenever two were on. That
tier is every visible text-bearing or opaque element under 15% of the
stage, so a title and a footer alone satisfied it — the check could not
fire on a composition that had any chrome, which is all of them.

Endpoints are now resolved per shaft, against candidates gathered by
layout instead of by visibility, so a node hidden with opacity:0 still
owns the endpoint that meets it and its visibility is what the rule tests.
An endpoint that meets no node is left to connector_detached.

* revert(lint): drop the marker rules this repo would only duplicate

marker_orient_typo and marker_dash_draw_on already run in Zephyr's Python
connector lint, down to the same regexes and the same message and fixHint
strings. Zephyr concatenates its local findings onto this bridge's result
with no dedupe, so shipping them here reports each one twice and counts it
twice, and every later edit has to land in two places.

The browser-check work stays: a marked shaft that meets no node, and an
orphan shaft, are both runtime facts this repo can see and a source-reading
rule cannot.

* chore(check): drop the comments added with the orphan rewrite

* fix(check): score orphan and detach on overflow-opted connector layers

Allow-overflow is a clip opt-out, not a lifecycle skip. Orphan now uses
the same connector names as detach so flow and arrow shafts are judged.

* fix(check): see axis-aligned shafts, and script tags closed with a space

connector_orphan gated on isVisibleElement, which rejects anything whose
bounding box has no height or width. A straight horizontal or vertical
connector is exactly that, and it is the common shape, so the rule was
blind to most of what it exists to catch: across 47 production
compositions it produced nothing. It now tests paint -- display,
visibility and the opacity chain -- and leaves size to the 80px chord
floor it already had. The same 47 compositions now yield three findings,
each naming the endpoint that is still dark.

unbalanced_style_tags stripped scripts with a regex that only matched
`</script>`. `</script >` is valid, so the script survived the strip and
a `"</style>"` string literal inside it counted toward the tag balance,
reporting an error on a composition whose tags are paired. Both the strip
and the closer count now tolerate whitespace before the `>`, which is also
what CodeQL flagged on this branch.

* fix(lint): count style tags in one pass instead of stripping scripts

CodeQL reads the `source.replace(/<script.../)` as an incomplete HTML
sanitizer. It was never one — the rule only needs to not count `<style`
tokens that live inside a script string. A single alternation scan, where
the script branch consumes the whole block, gets the same counts without
a replace.

* test(check): cover the two orphan guards that no fixture was crossing

Neutering `shaftIsPainted`'s opacity check, or the endpoint proximity
threshold, left the suite green. Both are false-positive guards: a
connector staged hidden before its reveal, and a hidden element that
happens to be the nearest thing to an endpoint without belonging to it.

The hidden-shaft case now runs over all four ways a shaft can be
invisible, so the display and visibility clauses are crossed too.

* test: pin the two boundaries raised in review

A closer inside an html comment is already dropped upstream by
stripHtmlComments, so the rule never sees it. The test fails if that
stripping regresses; adding a comment branch to the rule's own scan
would have reintroduced the polynomial-redos pattern that stripper
exists to avoid.

A node hidden with display:none has no geometry, so nothing can place it
near an endpoint and connector_orphan stays silent. connector_detached
does not pick it up either, since it allows a half-attached shaft by
design. That case is uncovered, and the fixture says so.

* fix(check): type the hidden-shaft fixture table as a style record

* fix(check): key connector_orphan by geometry and let a live node win the endpoint

Two defects found in review.

connector_orphan was persistence-tiered but absent from the geometry key,
so several id-less shafts orphaning at one sample each collapsed into a
single finding that then read as held rather than transient. On the 47
corpus compositions this recovers 9 findings across 5 compositions where
3 across 2 were reported; one composition fades its nodes and its
connectors on a shared stagger, so four shafts each outlive a different
node and only one of the four survived the collapse.

The endpoint scan took the nearest candidate and asked whether that one
was hidden, without asking whether a visible box was also in range. A
staged halo sitting on the node it belongs to produced a finding naming
the halo. A visible candidate within threshold now settles the endpoint.

Fixtures added for both, and for the two guards a mutation sweep found
unpinned: the connector-shape test and the dash-offset skip.

* test(check): kill every surviving mutant in the connector_orphan path

Seven guards still passed the suite when deleted: the four candidate
filters, the connector layer's own visibility gate, the defs/marker skip
and the chord floor. Each now has a fixture where that guard alone
decides the outcome.

The earlier sweep that missed them was mutating the wrong copy of a
shared line — connectorAnchorRects and connectorEndpointCandidates carry
the same filter text, and a first-match replace edited the detached one.
Mutations are scoped by enclosing function now.

* test(check): order the halo before the node it hides under

The halo fixture proved nothing. Both boxes contain the endpoint, so both
score gap 0, and the strict tie-break keeps whichever comes first in
document order — which was the visible node. The old code picked the same
winner and stayed silent too.

Putting the halo first makes the tie-break hand it the slot, so only the
visible-candidate check keeps the endpoint attached.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 01:22:48 +00:00
Xuanru Li c59fd162aa fix(check): key content_overlap and text_occluded collapse by pair, and bill a clipped text box once (#2801)
Combines two fixes to the layout audit:

- `staticIssueKey` no longer includes `issue.text` for `content_overlap`
  and `text_occluded`; both builders now emit a unique selector for both
  ends, so the selector pair is the identity. An animating subject
  (count-up, typewriter, rotating word) no longer splits one held
  collision into transient groups.
- A clipping box that is its own nearest constraint reports
  `clipped_text` only, instead of also reporting `text_box_overflow`.
  A non-clipping self-constraint still reports.
2026-09-06 18:54:18 -07:00
Xuanru Li 39ad2721b3 fix(check): keep auditing when a motion spec is partly unusable (#2805)
Combines two fixes to the motion sidecar path:

- An ambiguous selector no longer aborts the whole spec. It is reported
  as a finding, the assertions that depend on it are skipped and named,
  and every other assertion still evaluates. `assertionTargets` is now
  exhaustive over the assertion kinds so a new kind cannot silently fall
  back to an arbitrary first match.
- A sidecar that will not parse is reported as a spec finding instead of
  ending the run, so the composition is still audited.
2026-09-06 18:02:21 -07:00
James Russo b49fba8dea fix(cli): preserve concurrent scaffold config creation (#3721)
* fix(cli): preserve concurrent package metadata during init

* fix(cli): preserve concurrent project config creation
2026-09-05 19:35:41 -04:00
Miguel Ángel e147613542 fix(cli): upgrade project wrapper pins (#3042) 2026-09-04 18:06:51 +00:00
Miguel Ángel 2dc4bbfa75 feat(cli): forward a reported catalog gap to the feedback channel (#3204)
A search miss already reached PostHog, where nobody was watching it. It now
also goes to the same channel a CLI rating goes to, by the same best-effort
POST, so a move the catalog is missing is read rather than queried.

The forward is bounded and swallowed, and the ack prints before it, so a gap
report can never fail or delay the command that sent it.
2026-09-04 18:06:36 +00:00
Xuanru Li 45cc343525 fix(cli): flag caption-zone by DOM box overlap (#3580)
A card centered at y=.860 can still cover the painted V2A pill. Intersect the element's getBoundingClientRect with the keepout instead of testing whether its center sits inside the band.
2026-09-01 04:41:57 +00:00
heygengenesis[bot]andmiguel.sierra 9097d539b1 fix(cli): hide Windows child process consoles (#3529)
Rebase #3529 onto current main. Preserve all 16 issue-scoped Studio server, lint, and CLI child-process windowsHide options, including main's PowerShell null guards and stderr suppression in orphanCleanup.

Regression tests continue to assert windowsHide at each scoped spawn site. #3476 and #3430 remain out of scope.

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-08-31 18:11:20 -04:00
miga-heygen 73aa71c9ec fix(cli): guard PowerShell process queries against exited PIDs (#3571)
## Summary

`processIdentity` and `processParentPid` call `Get-CimInstance Win32_Process` to look up process metadata on Windows. When the target process has already exited, `Get-CimInstance` returns null and calling `.CreationDate.ToFileTimeUtc()` or `.ParentProcessId` on it throws `InvokeMethodOnNull`. The try/catch handles it, but PowerShell writes the error to stderr, which pollutes the test runner's output and causes spurious exit code 1 on Windows CI.

Two fixes per call site:
- Null-check the CimInstance before accessing properties (`$p = ...; if ($p) { $p.Property }`)
- `-ErrorAction SilentlyContinue` + `stdio: ["pipe", "pipe", "ignore"]` to suppress any residual stderr

Fixes the recurring `Tests on windows-latest` flake on main.

## Test plan

- [x] All 196 CLI test files pass locally
- [ ] Windows CI should no longer exit 1 from PowerShell stderr noise

— Miga
2026-08-31 17:15:39 -04:00
Miguel Ángel b28747df0f fix(cli): respect timeline-free static compositions (#3533) 2026-08-29 02:43:33 +00:00
Miguel Ángel 0c9d234bd8 Merge pull request #3481 from heygen-com/fix/web-audio-cross-origin-silence-v2
fix(core): prevent cross-origin Web Audio capture from silencing audio
2026-08-25 23:41:58 -04:00
cce17da5a9 fix(core): prevent cross-origin Web Audio capture from silencing audio
Classify each <audio> element before Web Audio capture: same-origin,
CORS-opted-in, or a non-http(s) scheme stays on the primary
createMediaElementSource() path; cross-origin media without a
crossorigin opt-in withholds that call (the Web Audio spec makes such a
node output silence without throwing) and falls back to fetch +
decodeAudioData, preserving the FX graph whenever the server allows
CORS. Recheck the route at the transport's irreversible capture
boundary, and account for currentSrc, src, and <source> candidates the
same way the HTML resource-selection algorithm does.

Emit a stable preview diagnostic (`runtime_web_audio_bypass`) at media
discovery time, not only from playback scheduling, so `hyperframes
check` — which seeks but never plays — can surface it as a
`web_audio_bypass` finding. Diagnostics are suppressed during export
rendering, where the producer mixes audio offline and already applies
the FX chain. The existing non-unit-rate fail-closed rule stays scoped
to fx-chain/automation so this fix does not newly mute grouped or
above-unity tracks.

Takes over #3459 with the data-native-audio escape hatch removed per
review feedback: the automatic cross-origin detection already covers
the cases that mattered, so the extra per-element opt-in attribute,
its route-classifier branch, and its diagnostic path are dropped in
favor of a single automatic behavior.

Fixes #3458

Original-Author: desenmeng
Co-Authored-By: desenmeng <desenmeng@users.noreply.github.com>
Co-Authored-By: Miga <noreply@anthropic.com>
2026-08-25 04:39:51 +00:00
Miguel Ángel 045b3a4fd7 feat(cli): report which catalog items a render actually used (#3470)
`registry_item_added` fires when a catalog block is installed and
`render_complete` fires when a video is produced, but nothing joined them, so
"did this video use the catalog?" had no answer.

`hyperframes add` now records each installed item in `hyperframes.json`
(installed files are plain composition HTML with no provenance marker, so this
manifest is the only record that a file came from the registry), and
`render_complete` reports both the items the project installed and the blocks
the rendered composition actually reaches. An item installed and then never
mounted was tried and dropped, which no add-time event can express.

The scan answering "which sub-compositions does this file mount" now has one
owner, `collectSubCompositionSrcs` in `@hyperframes/parsers`, shared with
lint's `lintMissingOrEmptySubComposition`. It holds two invariants that were
previously restated per call site and got re-derived wrongly: it is a text scan
rather than a DOM query, because `<template>` content is inert and every
sub-composition except the render entry is wrapped in one; and references
resolve root-relative at every nesting level, matching `parseSubCompositions`.
It walks tag by tag rather than running open-ended spans across the whole file,
so a malformed composition cannot stall the render plan.

Also: `registryItems` is declared in the config schema, which closes with
`additionalProperties: false`, with an ajv-backed test pinning every key the CLI
writes; counts are never truncated by the name cap, and the reported used blocks
stay a subset of the reported installed ones, with `registry_items_truncated`
marking a windowed list; and an unreadable manifest reports itself rather than
posing as a project that never used the catalog.
2026-08-24 19:56:06 -04:00
Santhi Prakash 95e1ac9f04 fix(skills): skip mirror fan-out to agents that read the universal store (#3325) 2026-08-24 09:41:19 -04:00
Miguel Ángel 8b67bb6db5 fix(cli,studio): surface project lint in Studio (#3393)
* fix(cli,studio): surface project lint in Studio

* fix(studio): preserve per-file lint coverage
2026-08-21 15:11:24 -04:00
Miguel Ángel a9ea07edde fix(cli): reject blank default composition entries (#3392)
* fix(cli): reject blank default composition entry

* fix(cli): complete blank entry safeguards
2026-08-21 11:00:43 -04:00
Miguel Ángel f822200fb8 feat(telemetry): measure which lint rules fire, cost, and fail to converge (#3367)
* feat(telemetry): measure which lint rules fire, cost, and fail to converge

Lint rule changes are currently argued from anecdote. This adds the three
measurements needed to argue them from data.

`lint_report`, once per `hyperframes lint` or `hyperframes check`:
- `code_counts` / `codes` — which rules actually fire, and how often
- `rule_group_ms` — milliseconds per rule-source module (core, gsap, media, ...)
- `slowest_rule` / `slowest_rule_ms` — slowest single rule as `<group>#<index>`
- `rule_count` — how many rules this build ran

`lint_rule_streak`, once per finding that survives an edit to its file:
- `edits` — how many edits the finding survived
- `cleared` — whether it eventually went away

The streak event is the one that matters. A lint pass costs about 5ms, so
per-rule CPU is not what makes the authoring loop slow; a rule an agent cannot
satisfy is, because every failed attempt costs a full edit-and-relint cycle. A
single run cannot see that, so `lint_rule_streak` reconstructs it across runs:
high `edits` with `cleared: false` is a rule nobody can fix, and the
`cleared: true` distribution is the baseline to judge it against.

An iteration is counted only when the file's content digest CHANGED and the
finding is still there. Re-linting an untouched project is not an attempt,
which is what stops `check` (which lints on every invocation) from inflating
the numbers.

Rule identity is the source module plus an index within it. Naming all 86
rules would make the timings prettier but it is a refactor this measurement
does not need: the group locates the file, and the index locates the rule.

Version, agent runtime, CI flag, and invocation id are already attached to
every event by `trackEvent`, so lint pain can be split by CLI version and by
which agent produced it without adding anything here.

Privacy: only rule codes, counts, and timings are sent. Streak state lives in
~/.hyperframes/lint-streaks.json alongside config.json (so `rm -rf
~/.hyperframes` is still a full reset) and stores digests only — no file
paths, no project names, no composition source. Nothing is written and nothing
is emitted when telemetry is off. Entries expire after 14 days and are capped
at 500 files.

`EventProperties` gains string arrays and numeric maps. `codes` and
`code_counts` are inherently a set and a histogram; flattening them into
dynamic top-level keys would make them unqueryable. PostHog stores both
natively.

`trackLintRun` is the single call site shared by `lint` and `check`, and it
swallows every error — telemetry must never turn a green lint red.

* feat(telemetry): emit per-group rule counts so slowest_rule stays comparable

Review catch on #3367: `slowest_rule` is the one positional key in either
event. It is `<group>#<index>`, so adding or removing a rule renumbers every
later slot in that group and the same string means different rules in two
builds. #3366 does exactly that to 34 of 81 surviving slots, and `rule_count`
alone says only THAT the ruleset moved, not which groups.

`rule_group_counts` carries the per-group sizes alongside it, so a consumer
comparing two builds can tell which groups' indices still mean the same thing
without anyone having to remember which release dropped rules. `codes`,
`code_counts` and `rule_group_ms` are keyed by name and were never affected.

Also corrects the rule count in the RULE_GROUPS comment: 86, not ~60, as
LINT_RULE_COUNT in the same file computes.
2026-08-20 18:26:14 -04:00
Miguel Ángel 83ceaeb902 refactor(lint): drop seven rules that fire on correct compositions (#3366)
Each rule below either reports a hazard the compiler or runtime already
prevents, duplicates another rule's invariant with a weaker detector, or
cannot be cleared by its own fixHint. Measured over the 643 shipped
registry HTML files, this cuts lint output from 1740 findings to 507
(-70.9%) and removes 40 errors, with no new codes introduced.

- scene_layer_missing_visibility_kill: regex heuristic keyed on `#sceneN`
  ids. It only accepts the literal string `visibility: "hidden"`, so the
  canonical GSAP hard kill (`tl.set(el, { autoAlpha: 0 })`, which sets
  visibility hidden at runtime) never clears it — an unfixable error. It
  also matched the `0` inside `opacity: 0.5` and treated `.from({opacity:
  0})` entrances as exits. gsap_exit_missing_hard_kill owns this invariant
  using parsed tween timing and real clip boundaries, and accepts every
  hidden encoding.
- unscoped_gsap_selector: wrapScopedCompositionScript already rewrites
  string GSAP targets to the composition root for every sub-composition
  script (pinned by compositionScoping.test.ts "executes document and GSAP
  selectors inside the composition root"). The rule also never fired on a
  standalone sub-composition file or a <template> sub-comp.
- caption_transcript_parse_error: required the inline TRANSCRIPT array to
  be strict JSON so Studio could read it, but Studio's parseTranscriptArray
  already normalizes unquoted keys, single quotes, and trailing commas. It
  errored on ten shipped caption components whose transcripts Studio parses.
- composition_self_attribute_selector: warned that
  `[data-composition-id="x"] .y` leaks across instances, but
  scopeCssToComposition rewrites that selector to each instance's runtime
  scope. It was also the pattern the rest of the toolchain prescribes.
- timed_element_missing_visibility_hidden: strict subset of
  timed_element_missing_clip_class, which reports the same condition as an
  error, so it only ever added a second line saying the same thing.
- pointer_events_none: Studio selection ergonomics only, no render impact,
  on 124 of 211 shipped blocks.
- google_fonts_import: the producer resolves Google Fonts during
  compile/render, as the message itself said.

system_font_will_alias is narrowed to distributed/Lambda renders, where
system-font capture is off and the fallback is a real defect. Under a local
render the substitution is the renderer working as designed, so the info
tier is gone.

The three tests that used composition_self_attribute_selector as a probe
for "this style source was collected" now use scoped_css_missing_wrapper,
which still fires once per source.
2026-08-20 18:10:24 -04:00
Miguel Ángel d464f60b96 fix(cli): zip the publish archive to the same bytes every time (#3358)
adm-zip stamps every entry with `new Date()` as it is constructed, and a ZIP
timestamp resolves to two seconds — so archiving identical content twice gave
different bytes whenever the two runs landed either side of a boundary. The
archive's digest was a function of the clock rather than of its contents, which
is backwards for something `cloud render` uploads and addresses by content.

It surfaced as a CI flake: publishProject.test.ts asserts two archives built
back to back are byte-identical, and both sides are the same expression, so the
only way it can fail is non-determinism. The window is narrow, which is why it
survived since July and why re-running always cleared it.

Entry times are now fixed. Built from local components deliberately:
`fromDate2DOS` reads getFullYear/getMonth/getHours, so a fixed instant would
still encode differently per timezone — verified identical bytes under UTC,
America/Los_Angeles and Asia/Kolkata.

The new test moves the clock across a boundary, which is what reproduces it;
back-to-back builds land in the same bucket almost always, which is exactly how
it hid.
2026-08-19 18:25:04 -04:00
Miguel Ángel 9da422fd7f feat(cli): run a managed background preview in every launch mode (#3310)
`--background` was rejected outside the embedded server. It now re-execs the
CLI in foreground, which makes it mode-agnostic by construction: whichever
server the child resolves to serves the config endpoint the readiness probe
looks for. `--foreground` is its counterpart, for a non-interactive shell that
wants to stay attached, and a bare launch keeps the same promise — attached in
an interactive terminal, managed in an agent session.

That generalization exposed an existing hole. Local-studio mode runs Vite with
the studio package as its cwd and needs that package's own Vite config, which
the published tarball does not carry, but resolving the package was treated as
proof the mode was usable. An npm-installed studio therefore took a path that
can never come up — previously a clear error, now a ten-second silent timeout.
The predicate becomes "can this studio actually be served", so a published
install falls back to embedded mode, which works.

Over the 1k line budget at ~1.3k. The overage is one command file and its
tests carrying one invariant, and the seam that would split it further is
inside a single request-handling function — a split there would produce two
PRs neither of which starts a preview on its own.
2026-08-19 17:02:44 -04:00
Miguel Ángel 74149e249a fix(cli): keep a live preview's ownership record and stop past a bad one (#3308)
* fix(cli): keep a live preview's ownership record and stop past a bad one

A missed liveness probe is not proof the preview is gone — a server blocked on
a Puppeteer capture answers nothing for a second or two — but any miss retired
the session record, and the record carries the only PID-reuse guard `--stop`
has. Reproduced by SIGSTOPping a managed preview and running `--status`: the
record was deleted and never came back, leaving every later stop to fall
through to an unauthenticated port scan with no ownership proof at all. Only a
wrapper process that is provably gone now retires a record.

That record gains a process-birth token so a recycled PID reads as a different
process, and it is written through a temp file and renamed — every reader
deletes it when it fails to parse, so a torn read would otherwise destroy a
live server's proof of ownership.

Two failure-propagation bugs in the stop path: `--kill-all` collected the
first unprovable record's exception and abandoned every server after it, so
they were left running AND unreported; and a replacement refused to launch
when the server it was replacing had already exited on its own, which is the
goal state rather than a failure. `--list` now shows managed sessions ahead of
whatever else answers the scan.

* fix(cli): keep a record whose identity lookup gave no answer, not a different one

Review blocker. The keep-alive path this PR adds could still retire a LIVE
record — through a different door than the one it closed.

`processIdentity` catches every failure into `null`, and on two of three
platforms that failure is a subprocess timeout on a live process: the win32
`Win32_Process` CIM query and the POSIX `ps -o lstart=` both run on a 2 s
budget, under exactly the load that made the HTTP probe miss in the first
place. A `null` compared unequal to the saved token, so the record was deleted
and `wrapperIdentity` — the only PID-reuse guard `--stop` has — was gone for
good. Only Linux, reading /proc directly, was reliable.

No answer is now distinguished from a different answer: the PID is checked with
`kill(pid, 0)` first, which asks the kernel without signalling and treats EPERM
as alive. A PID nothing can signal is gone and retires the record with no
subprocess at all; a signalable PID whose token cannot be read keeps it. Only a
token that comes back and differs retires it.

That ordering also answers the `--list` note: the identity subprocess no longer
runs for the stale records that made it slow, so the N x 2 s worst case is gone
along with the timeouts that fed the bug.

Verified by mutation: restoring the old "no answer means gone" behaviour reds
the new case. Also clean up the temp file when a rename fails, rather than
orphaning it in the session directory.

* test(cli): assert only what the birth-token lookup actually guarantees

`captures a stable birth token for the current process` made two assertions
that a lookup allowed to fail cannot support. `processIdentity` returns null
whenever the lookup cannot be completed — not only when the process is absent —
and on Windows and macOS it shells out to PowerShell or `ps` on a 2 s budget
that a cold CI runner routinely outruns.

Both failed on windows-latest, in sequence: first `.toMatch()` received null,
and once that was guarded, `expect(second).toBe(first)` compared a null from the
cold first spawn against a token from the warm second one.

Two lookups can disagree for exactly one reason — one of them failed — so
stability is only assertable across two successful ones. The token itself
cannot change between calls; it is a birth timestamp and the process did not
restart. `processIdentity(-1)` stays unconditional: the guard rejects it before
any subprocess runs.

The strict shape assertion moves to a Linux-only case, where /proc is read
directly with no subprocess and null is genuinely not allowed — keeping the
guarantee on the one platform that can honour it rather than dropping it
everywhere. Callers already depend on this contract: `wrapperProcessIsAlive`
treats null as "no answer" rather than "gone" precisely because it is reachable.
2026-08-18 19:50:02 -04:00
Miguel Ángel c1c70f44bd fix(cli): signal only processes the OS says own the port (#3307)
* fix(cli): signal only processes the OS says own the port

`/__hyperframes_config` is unauthenticated and the PID it reports is what
`--stop` and `--kill-all` send signals to, so any local process answering on
a scanned port could name an arbitrary PID and have the CLI kill it.
Reproduced with a twenty-line HTTP server on a scanned port self-reporting an
unrelated PID: before this, `--kill-all` killed that process; after it, the
process survives and only the real listener is stopped.

The listening PID now comes from the OS — `lsof`, and `netstat` on Windows,
where the lookup was previously unavailable and the self-reported value was
taken on trust. The response's own PID is used only where the OS lookup
fails, which is also the only case where it is unfalsifiable.

Orphan cleanup moves to the last step before a launch. It reaches outside the
process and kills other people's PIDs, so it must not run for an invocation
that turns out to be a validation error and never starts anything.

* fix(cli): fail closed when the OS cannot confirm who owns a port

Review follow-up.

The two halves of this change picked opposite directions for the same
condition. `isProcessDescendant` fails closed by design; `activeServerOnPort`
fell back to the self-reported PID whenever the OS lookup came back empty —
and that is not only "unsupported platform". `lsof` may be absent (the default
on many slim images), may time out, or may not see a socket owned by another
user. On such a machine every scanned port silently reverted to pre-change
behaviour, with nothing said.

Provenance is now part of the type rather than a convention: `ActiveServer`
carries `pidSource`, so a caller cannot mistake a self-report for the kernel's
answer. `--kill-all` requires `"os"` and skips the rest, naming the ports it
left alone and why. That is the deliberate trade — a blind sweep of a port
range has no evidence beyond an unauthenticated response, so an unconfirmed
PID must not be signalled. Managed previews are unaffected: they stop through
their session record, which proves ownership by process birth identity.

The fallback branch — the one with the security consequence — now has the
coverage it lacked, via an injected lookup matching the seam `testPortOnAllHosts`
and `isProcessDescendant` already use, including a live process that survives
because nothing confirmed it owns the socket.

Also state that `killProcessTree` honours `signal` on POSIX only: Windows
always passes `/F`, deliberately, since taskkill without it posts WM_CLOSE that
a console process may ignore. The caller-side comment claiming Windows cleanup
is a no-op described the code before this change and now says the opposite.
2026-08-18 17:41:46 -04:00
Vance IngallsandClaude Opus 5 56d8df65ca docs(skills): add /hyperframes-audio, and key the waveform cache by file (#3211)
* feat(studio): show every automated knob at the playhead, and carve as one module

An automated parameter has two values: the number sitting in the chain, which is
only the seed a lane replaced, and the number the envelope is on right now. The
second is the true one, so the panel shows it — on the carve rack's readouts and
on every effect's own fader and number field. A rack that showed the seed stood
still while the carve was audibly working.

Off the clip it keeps sampling rather than falling back to the stored number: a
lane holds its first value backwards and its last forwards, so before the clip
starts it already knows what it will open on, and the stored seed is a value
nothing will ever play. Showing it made the fader jump the moment the clip came
under the playhead.

The playhead comes off the liveTime channel, throttled to 30 Hz — the RAF loop
deliberately keeps frames out of the store, so a panel watching only the store
would sit still for a whole take. PropertyPanel had that subscription inline;
it is now one shared hook with two callers.

Readouts reserve the width their parameter can need rather than what its current
value takes, because an updating value one character narrower shunted everything
after it sideways 30 times a second.

The carve's effects are presented as one module: an author switched on a carve,
and the peaking filters plus the level stage are how it is built, not six things
to remove one at a time. Opening it lists every member's settings as readouts,
since strength is what sets them. No carve control is offered on a track another
track already carves against — that track is the voice, not the bed.

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

* fix(studio-server): key the waveform cache on the file, not just its path

Two takes written to the same path returned the first one's waveform, so a
re-recorded track drew the shape of the audio it replaced. The key now carries
size and mtime, which is enough to notice the bytes changed.

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

* feat(engine): render audio FX in an OfflineAudioContext

Reads `data-fx-chain` off an audio element and runs the chain over the trimmed
WAV before volume automation is baked in — effects should see the raw signal,
and the envelope belongs on their output.

The processing happens in an OfflineAudioContext inside the headless browser
the engine already drives, running the same graph builders the studio previews
with. That is the point of the approach: one implementation per effect, so the
render agreeing with the preview is a property of the architecture rather than
a tolerance to police. Reimplementing each effect as an FFmpeg filter would
mean two implementations to keep in step, and for the dynamics processors and
modulated delays there is no filter that behaves the same way.

`build:audio-fx-runtime` bundles the graph builders into an injectable IIFE,
following the same pattern as the existing runtime artifacts, so the browser
runs exactly the code the studio does.

The page loads from a file:// URL rather than about:blank because AudioWorklet
is only exposed in a secure context — the compressor, limiter, gate and
bitcrush processors would otherwise fail to register with an opaque error.
file:// qualifies and needs no listening socket.

The chain is serialised into the attribute the way colour grading carries its
config, so there is no side-car file to resolve or lose.

An FX failure is fatal for the whole mix rather than a per-track soft failure.
Every other audio failure mode degrades gracefully — the track drops, siblings
continue — but substituting the dry signal for a processed one ships a render
that sounds plausible and is not what the author set up. Since the per-element
work races under Promise.all, an internal AbortController chained off the
caller's signal aborts in-flight siblings before workDir is removed.

* feat(core): voiceover carve analysis

Finds the bands a voice occupies so a music bed can be dipped there, letting
the voice sit in front without ducking the whole track.

Carve is a relationship between two tracks rather than an effect on one, so it
stays out of the FX chain. What it emits is an ordinary chain of peaking
filters, so a carve composes with whatever else is on the track and needs no
separate rendering path.

Selection is weighted toward intelligibility rather than raw voice energy.
Ranking purely by power lands on the fundamental almost every time, because
that is where a voice is loudest — but the masking that actually hurts a
voiceover happens higher up, and dipping 160 Hz mostly just thins the bed. The
bias is a control, not a constant: at 0 it follows raw energy, at 1 it weights
toward 1-3 kHz.

Ranking happens in dB, which matters more than it looks. Speech spreads 20-30 dB
across these bands — it falls off roughly 6 dB per octave above the fundamental
— so a weighting has to be on that scale to move anything at all. A
multiplicative weight of `1 - bias + bias * shaped` is bounded below by
`1 - bias`, capping its influence at 10*log10(1/(1 - bias)): 5.2 dB at the 0.7
default, 3 dB at 0.5. That is no influence against a real voice — every bias
short of ~0.95 would rank exactly like bias 0 and carve the fundamental, the
outcome the bias exists to prevent, while looking decisive against a fixture
whose bands sit 2 dB apart. So the bias is a dB penalty, zero at 2 kHz and worth
up to 30 dB at full strength, and relative cut depths come from a dB difference
rather than a ratio of weighted linear powers.

The bias reweights ranking without overriding the spectrum — a band the voice
has no energy in is not worth carving, and scores -Infinity rather than
competing — so a strongly low-pitched voice can still select low at full bias.
What the tests hold is that biasing never selects lower than the unbiased
ranking, that the DEFAULT bias reaches the presence region on a voice with a
realistic tilt, and that bias 0 still follows raw power exactly.

Includes a radix-2 FFT rather than a dependency; one Welch-style averaged
spectrum over third-octave bands does not justify pulling in a DSP library.

* fix(engine): keep the FX render 16-bit, stereo, and correctly sized

Three defects in the offline FX path, none of which any test could see.

**Float output silently disabled sample-accurate volume automation.** The writer
emitted 32-bit IEEE float; the very next mixer step bakes the volume envelope
into the samples and accepts only 16-bit PCM, returning null otherwise. So
enabling any effect downgraded that track to the ffmpeg expression path — capped
at 32 straight segments, quantising a curved envelope, and on a dense one falling
back to base volume. It now writes 16-bit PCM, clamped rather than wrapped so a
limiter at 0 dB or a resonant filter cannot turn overshoot into a click. A test
asserts the baker accepts the writer's own output and actually fades it.

**Everything was folded to mono.** `prepareAudioTrack` goes out of its way to
emit stereo — its pan filter exists to dodge ffmpeg's 3 dB mono-to-stereo
rematrix — and this folded it, then wrote one channel. So adding a single peaking
EQ collapsed a bed's width and cost ~3 dB in the render, while preview stayed
stereo. Channels now travel as one plane each, through an OfflineAudioContext of
the same width, and come back interleaved.

**Small results decoded the wrong length.** `new Float32Array(buf.buffer)`
discards byteOffset and byteLength, and Node pools small allocations: a 400-byte
payload sits at offset 8 inside an 8 KiB pool, so a clip under ~1024 samples
decoded as 2048 samples of unrelated memory — and the empty-result guard could
not see it. The reader has the mirror-image fix: a float data chunk on an odd
boundary (ffmpeg's pcm_f32le writes fmt(18) + fact, landing `data` at 58) now
copies instead of throwing RangeError on an unaligned view.

The tail limitation is now stated rather than mis-stated: the context is exactly
as long as the input, so a reverb or delay still ringing is cut there. The old
comment claimed the opposite. How far a tail may run past a clip's end changes
the clip's length in the mix, so it is a product decision, not one to make here.

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

* fix(producer): report an FX render failure as an audio error

`processCompositionAudio` reports per-track failures in its result, but an FX
failure it cannot degrade past — a browser that will not launch, a chain that
will not build — rejects instead. `runAudioStage` had no try, so that rejection
escaped to the orchestrator as an unclassified pipeline exception, losing the
stage/owner/retryable classification this stage exists to attach, and skipping
its abort check on the way out.

It now lands in `audioError` alongside every other cause, while an abort still
keeps its own shape rather than being reported as an audio problem.

Not done here: committing the generated `audio-fx-runtime-inline.ts` so a fresh
clone typechecks packages/engine without building first. The bundle is built from
the stub, and the stub changes three times across this stack — so the artifact
differs per branch and would conflict on every restack. Its model,
position-edits-render-inline.ts, is committed only because it is stable. Building
before testing is this monorepo's existing contract (studio's tests need core's
dist too), so the gap is not specific to audio FX and is better closed by a build
ordering gate than by committing a per-branch artifact.

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

* test(engine): skip the browser FX render cases when there is no browser

CI's `Test` job was red on this PR with four failures, all the same cause:

  Failed to launch the browser process: spawn
  /home/runner/.cache/hyperframes/chrome/chrome-headless-shell

The job installs ffmpeg and no browser, deliberately — every other suite
that needs an external binary already guards on it
(`describe.skipIf(!HAS_FFMPEG)`). These cases were the only ones assuming
a Chrome, so they failed on an absent dependency rather than on anything
about the code.

Guards on `resolveHeadlessShellPath()` — the same resolver
`acquireBrowser` launches through, so the check cannot drift from the
thing it guards the way a hard-coded cache path would. A configured path
that does not exist throws; that is caught and read as "cannot run here".

Checked both directions rather than just the green one: with a browser all
11 cases run and pass, and with `HYPERFRAMES_BROWSER_PATH` pointed at a
missing binary exactly 3 skip and the other 8 still run. A guard that
silently skipped everything would have looked identical in CI.

They keep their value where it exists — every developer machine, and any
job that has run `hyperframes browser ensure`.

Not touched: the CodeQL failure on this PR is a run from 2026-08-07, five
days and several force-pushes stale. None of the 17 open repo alerts are
in files this PR changes; it re-runs on this push.

* chore(engine): suppress the temp-file alert with the reason it is safe

CodeQL flags `writeWav`'s `writeFileSync` as js/insecure-temporary-file
(high) — the one new alert on #3021, and the reason its CodeQL check is
red.

It is a false positive, and the comment says why rather than just silencing
it: `path` is always inside a directory made by `mkdtempSync`, never a
name assembled directly under `tmpdir()`. Both callers are covered — the
browser host page writes into `mkdtempSync(join(tmpdir(), "hf-fx-host-"))`,
and the render output goes to the producer work dir, itself
`mkdtempSync(join(tempRoot, "producer-project-"))`. mkdtemp picks the
random suffix and creates the directory 0700 in one syscall, so the
predictable filename inside it cannot be pre-created or symlinked by
another user, which is the attack the rule is about. The analyzer sees the
dataflow reach `tmpdir()` and not the mkdtemp in between.

Suppressed inline rather than dismissed in the UI, so the justification
lives next to the code and the rule stays live for anything added later in
this file. Matches the repo's existing convention — `planV2.ts:222`
carries an `lgtm[js/insecure-temporary-file]` for a different reason on
the same rule.

Correcting myself: I first reported this alert as not real, having
intersected the PR's files against the default-branch alert list, which
does not contain PR-ref alerts. Querying ?ref=refs/pull/3021/merge returns
it straight away.

* test(engine): probe ffmpeg and Chrome instead of assuming them

Two failures on #3021's Test job, both about the environment rather than
the code under test.

**Bare `ffmpeg` is not on PATH in CI.** The 16-bit fixture shelled out to
`execFileSync("ffmpeg", ...)` and died with ENOENT. The job does provide
ffmpeg, through `prepare-ffmpeg-bin`, which is what `getFfmpegBinary()`
resolves — every other ffmpeg-dependent suite in this package already goes
through it. Now this one does too, and the case is `skipIf(!HAS_FFMPEG)`
so a contributor without ffmpeg skips rather than fails.

**The browser guard trusted the wrong thing.** It asked
`resolveHeadlessShellPath()` and treated a returned path as "a browser is
here". CI's cache holds a chrome-headless-shell that resolves and then
fails to spawn — a partial download is indistinguishable from a working
one by `existsSync`, which is all that resolver checks. So the three
browser cases ran anyway and failed on the launch.

It now runs `--version` and requires exit 0, which is the same probe the
ffmpeg suites use: ask the binary, do not infer from the filesystem.

Checked both directions rather than just the green one. With a working
browser all 11 cases run and pass; with `HYPERFRAMES_BROWSER_PATH` pointed
at a binary that exits non-zero — CI's exact situation — exactly 3 skip
and the other 8 still run. A guard that quietly skipped everything would
have looked identical on the CI summary.

* feat(core): register the audio-fx-rack canary at 0%

Lands the rollout switch dark, per the registry's own procedure: "Start at
percentage: 0 and merge that — a canary at 0 is dead code you can land
safely and ramp without a code review."

Declared at the bottom of the stack so every branch above can read it. The
gate itself goes in at wa-4-fx-panel, where the rack first appears.

Scope is deliberate and stated in the description: it gates the AUTHORING
surface only. A composition that already carries `data-fx-chain` still
plays and renders it. A canary should stage who can REACH a feature, not
make an attribute somebody already wrote silently inert — an agent that
writes a chain through the skill would otherwise produce a file whose audio
processing vanishes with no error.

* feat(studio): audio FX panel generated from the registry

Controls for the whole chain: add, remove, reorder, bypass, and every knob each
effect declares.

Nothing in the panel knows what a compressor is. The registry supplies each
parameter's range, step, unit and scale and the panel renders what it finds, so
adding an effect or a knob upstream needs no change here, and the panel cannot
offer a value the renderer would reject — a typed-in figure is clamped into the
declared range on the way through.

Frequency and time controls span three or four decades, so those declare a log
scale and the slider maps exponentially; a linear slider would spend most of
its travel somewhere useless.

Reorder is a first-class control because chain order changes the sound: a
reverb before a compressor is not the same as after.

Carve gets its own block rather than an entry in the add menu, with a picker
for the voice track to listen to. It processes this track based on another one,
which is how a sidechain control works — it lives on the track that changes,
and names the source.

* feat(studio): show the Audio FX section on audio tracks

Adds `audioFx` to the editing-affordances contract and renders the FX panel in
the inspector when an `<audio>` element is selected.

The section is audio-only. A `<video>` carries its sound on a separate
`<audio>` element, so an FX chain on the video would have nothing to process.

Chain and carve settings are written straight back onto the element as
serialised attributes, the way colour grading carries its config, so
persistence is an ordinary attribute write and needs no new server route. A
chain that cannot be parsed renders as empty rather than breaking the panel,
and the attribute is left untouched until the user changes something.

The collapsed group summarises what is on the track ("2 effects + carve") so
the state is visible without expanding it.

Wired into PropertyPanelFlat rather than PropertyPanel: STUDIO_FLAT_INSPECTOR_ENABLED
defaults to true, so the flat inspector is what actually renders.

* refactor(studio): lift audioFxSummary out of PropertyPanelFlat

`PropertyPanelFlat.tsx` is 612 lines here against the repo's 600-line cap,
so the required File size check is red — the sole reason this PR is
blocked. The review says as much: "mechanical fix (~5 min), not a design
problem. Code itself is LGTM."

Moves `audioFxSummary` to `audioFxSummary.ts`, the same file a later
branch creates for it. Deliberately the smallest cut that clears the cap
rather than the whole `AudioFxGroup` extraction: every later commit in the
stack edits AudioFxGroup, so moving it here would collide with each of
them, while almost nothing touches this function.

595 lines.

* feat(core,studio): hear the FX chain in preview, and run the carve analysis

Splices an element's FX chain into the playback graph so preview stops being
silent about effects, and wires the carve button that was previously inert.

The chain goes between the decoded source and its gain stage: effects see the
raw signal and volume automation rides on their output, matching the order the
offline render uses. Since preview and render call the same graph builders,
what is heard while scrubbing is what gets written.

The splice lives in the transport rather than on the `<audio>` element. The
transport plays each track from a decoded AudioBuffer and mutes the element to
avoid doubling, so capturing the element with createMediaElementSource would
have processed a stream nothing is listening to — it looked like it worked
because the call succeeded, and the audio was unchanged.

A chain that cannot be built plays dry rather than silencing the track, which
is the right failure in preview: the author keeps working and hears the source.
The render still refuses, because shipping the dry signal there would be wrong.

Carve now analyses for real: it decodes the chosen voice track, ranks its bands
and writes the resulting peaking filters onto this track. Generated nodes are
tagged `fromCarve`, so re-running replaces the previous carve instead of
stacking another set on top of hand-added effects.

Known limitation: the graph is built when a source is scheduled, so a knob
turned mid-playback takes effect on the next play or seek rather than
immediately. Live re-parameterisation needs the transport to hold the handle
and forward updates.

* fix(studio,core): stop parameter drags from restarting playback

Dragging a knob wrote the chain through the persisting attribute path on every
input event. That path refreshes the preview, which reloads the composition and
reschedules audio — so a single drag reloaded dozens of times and playback
stuttered the whole way.

Drags now go through `onSetAttributeLive`, the same path colour grading uses for
scrubs: it coalesces undo entries and sets `skipRefresh`, so no reload happens.
The persisting write fires once, when the gesture ends — pointer-up or blur for
a slider, Enter or blur for a typed value. A select commits immediately since
there is no drag to wait for.

While dragging, the control is driven from local state. Waiting for the value to
round-trip through the element attribute made the knob lag behind the pointer.

For the change to be audible without a reload, the graph now follows the
attribute: the chain installed by the transport observes the element and
re-parameterises itself in place, so a value change lands on the next
128-sample quantum. A shape change (effect added, bypassed, pole count) cannot
be patched into a running graph, so it still waits for the next schedule rather
than cutting the audio mid-play.

The regression test drags a slider through several values and asserts the
persisting handler is untouched until release.

* feat(studio): put the audio FX rack behind its canary

Gates the rack on `isCanaryEnabled("audio-fx-rack")`, which is registered
at 0% — so the whole 47-PR stack can land without showing anyone a feature
that has not been measured yet.

The gate sits on the AUTHORING surface and nowhere else. The runtime and
the render still honour a `data-fx-chain` already on an element, so a
composition written through the skill or by `carve.mjs` keeps its
processing rather than going silently dry for anyone outside the cohort. A
canary should stage who can REACH a feature, not make an attribute somebody
already wrote stop working with no error.

Gated at the panel rather than in `resolveEditingSections`: the affordance
resolver is a pure function in core describing what an element CAN support,
and rollout state is not a property of an `<audio>` tag.

Pinned the 0% with a test, and checked it fails at 25 — a ramp should have
to break something that says "this ships dark" out loud.

One gap, stated rather than papered over: the gate itself has no unit test.
I wrote one and deleted it, because `PropertyPanel.test.tsx`'s harness
never renders the Audio FX group for its audio fixture even with the gate
removed — so the test passed for the wrong reason in the off case and could
not pass at all in the on case. A test that cannot fail for the right
reason is worse than none. Verifying the gate needs the panel harness to
mount that section first, which is its own change.

* fix(core): register FX worklets before building nodes that need them

An AudioWorkletNode cannot be constructed before its processor is registered —
it throws, and the surrounding chain is lost with it. `attachElementFxChain`
built the chain first and only then called `ensureAudioFxWorklets`, so every
worklet-backed effect (compressor, limiter, gate, bitcrush) threw on
construction and the track fell back to dry. Instrumenting the preview showed
`hf-compressor: InvalidStateError` with addModule never called at all.

When the module has not landed yet the track now plays dry and the graph is
swapped in once registration resolves, so the effect arrives a moment late
instead of never.

Registration is also tracked per context rather than in one module-level
promise. A processor registered on one AudioContext does not exist on another,
so the shared promise made every context after the first believe it was ready
when it was not — the studio's transport owns its own context, which is exactly
that case.

With the worklets actually running, the compressor's per-sample log10 and pow
became real audio-thread work. Samples below the knee have a gain of exactly
unity and need neither, so the envelope is now compared in the linear domain
and the transcendentals only run for samples that are actually being
compressed.

* refactor(studio): split the FX node row out of FxSection

Clears the health findings the FX stack left behind: the chain-node render
callback was a 70-line closure over half of FxSection's state, and the two
reorder arrows were the same button written twice.

Also drops two exports with no consumers, and registers the audio FX runtime
stub as an entry point — it is bundled by file path, so nothing imports it.

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

* feat(core): automation envelope model for audio tracks

Adds the data model behind Ableton-style automation lanes: breakpoint
envelopes over track volume or one knob of one effect in the track's FX
chain, stored on the element as `data-automation`.

Times are clip-local, so an envelope travels with the clip when it moves —
the clip-envelope model rather than arrangement automation.

`sampleAutomationLane` is the single interpolator. The lane drawing, the
preview scheduler and the render bake all call it, so the picture and the
sound cannot disagree about the curve. Log-scaled parameters interpolate in
log space, matching what their own knob already promises.

FX nodes gain a stable `id`, minted by count rather than randomly so the
document is the same on every machine. Lanes address nodes by id, so
reordering a chain never re-points a lane at a different effect, and a lane
whose effect was deleted is dropped rather than left to reattach.

Also warns when a track carries both a volume lane and a GSAP volume tween,
since only the lane is heard and the tween silently does nothing.

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

* refactor(studio): lift the audio FX group out of PropertyPanelFlat

`PropertyPanelFlat.tsx` was 672 lines against the repo's 600-line cap, so
the required File size check was red — the sole reason #3014 and #3022 are
blocked. Both reviews say the same thing: "mechanical fix, not a design
problem. Code itself is LGTM."

Moves `AudioFxGroup` and `audioFxSummary` into
`propertyPanelAudioFxGroup.tsx`, which is where a later branch puts them
anyway — done here so the file is under the cap from the point it first
crosses it, rather than ten branches later.

533 lines now. The four audio imports it no longer needs go with it.

Not fixed here: three `FxSection carve` tests fail on this branch with
"Cannot read properties of undefined (reading 'toFixed')". Confirmed
pre-existing by stashing this change and re-running — that is the separate
`Test` failure the review also flags.

* feat(core): expose the AudioParams behind automatable FX knobs

Marks the knobs an automation lane can drive and has each graph builder hand
back the AudioParam behind them, so a scheduler can write to a running effect
without knowing what the effect is.

A knob is not always one AudioParam. A wet/dry mix is two gains moving in
opposition, and a knob in milliseconds drives a delay time in seconds, so
each target carries the mapping out of the knob's own declared unit.

What stays unautomatable is stated where it is decided: a WaveShaper curve, a
convolution impulse and a one-pole filter's coefficients are all rebuilt
wholesale rather than scheduled, and the four worklet effects take values by
postMessage rather than through AudioParams.

The registry flag is written by hand, so a test builds every effect and
checks the exposure both ways — nothing flagged is missing, nothing exposed
is unflagged. A flag that lied would offer a lane that silently did nothing.

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

* feat(core): play automation envelopes in preview

Schedules each lane onto the AudioParams behind its knob using native ramps
and value curves. Nothing evaluates the envelope per frame: it is handed to
the audio thread once, so it stays sample-accurate however busy the main
thread is, and the offline render will schedule it the same way.

Timing comes from the transport, so an envelope survives seeking into the
middle of a clip, a clip that has not started yet, and a playback rate that
compresses clip seconds into context seconds.

A straight line is only scheduled as a ramp when nothing bends it — no
curvature, a linear parameter scale, and no unit mapping. Log-scaled
parameters and mapped ones are sampled instead, since a delay knob in
milliseconds and a wet/dry pair moving in opposition are not linear in the
parameter they drive.

Lanes with nowhere to write are skipped rather than reported: a one-pole
filter exposes no frequency param, and the worklet effects expose none at
all. Editing an envelope mid-playback re-aims it at the live playhead rather
than restarting the track.

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

* fix(core): make the volume lane audible in preview

The envelope was scheduled onto the transport's gain AudioParam, but the
runtime rewrites that gain every tick from `data-volume` and the GSAP-seeked
value — so it was erased within a frame. Volume automation was correct in the
render and inaudible while previewing.

The lane now feeds the per-tick path where the probed volume keyframes already
sit, checked ahead of them so the two cannot fight, and the transport no
longer schedules volume at all: one mechanism instead of two racing.

The cost is honest — in preview the level steps per tick rather than per
sample, exactly as the existing keyframe path does. The render still bakes it
into the PCM sample-accurately, and FX parameters are still scheduled on their
own AudioParams, since nothing rewrites those.

Parsed lanes are cached by attribute text: the runtime asks once per tick per
track, and parsing there would run the JSON parser 60 times a second for a
value that only changes on an edit.

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

* feat(engine): bake automation envelopes into the render

The offline render schedules FX lanes with the same scheduler preview uses,
inside the OfflineAudioContext that already runs the same graph builders. The
input WAV is the clip's own audio from its first sample, so clip-local time
is offline time and the envelope needs no offset.

Volume lanes take the existing PCM bake rather than a second mechanism: the
lane is converted to keyframes, so a straight fade stays two of them and only
a bent segment is sampled — the baker interpolates linearly and would
otherwise quietly straighten the curve. A volume lane supersedes keyframes
probed from the timeline, which `lint` already warns about.

A browser test sweeps a lowpass from below a 2 kHz tone to well above it and
measures both ends. Parsing the envelope is not the same as scheduling it,
and only running the real thing tells the two apart.

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

* fix(core): apply chain edits to the running graph

A structural edit — an effect added, removed, bypassed, or a filter's pole
count switched — was dropped. `buildFxChain`'s update reports false when the
change is not merely new values, and the attribute observer ignored that, so
the edit only took hold when the persisting write reloaded the composition.
That reload restarted every playing track, which is what was heard as the
audio chopping.

The graph is now swapped in place: the old effects are detached, the new ones
built and connected between the same source and gain, and any lanes
re-scheduled onto the new nodes. The source node is never touched, so playback
does not restart.

A track with no chain is watched too, rather than wired through and forgotten,
so adding its first effect is heard the same way. That means the function
always returns a disposer instead of null for the empty case.

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

* fix(studio): drop the FX panel's dead __testables export

Fallow audit flagged it — no test imports the module.

* fix(core,studio): clear the remaining Fallow audit findings on the FX panel

- Split FxSection's per-node row into FxNodeRow + FxNodeControls so the
  CRAP score (31.6, threshold 30) splits across two smaller units instead
  of moving wholesale with one extraction.
- Dedupe the repeated "open the add menu, read its items" block in
  propertyPanelFxSection.test.tsx into openAddMenuItems().
- Merge build-audio-fx-runtime.ts and build-position-edits-render.ts into
  one build-inline-artifact.ts, config-selected by CLI arg — the two
  scripts were a byte-for-byte clone save for names.
- Exempt canary.test.ts's rawFnv (a deliberate independent
  reimplementation used to cross-check canaryBucket, per its own
  docstring) and the property-panel test files' shared renderInto/mount
  scaffolding (pre-existing across 9 files, 2 outside this stack) in
  .fallowrc.jsonc, consistent with this file's existing exemptions for
  the same class of intentional/pre-existing duplication.

* fix(ci): allowlist the build-script consolidation in the no-main-deletions guard

build-audio-fx-runtime.ts and build-position-edits-render.ts were merged into
build-inline-artifact.ts to kill a fallow duplication finding; the deletion
guard flagged that as an accidental loss since main still has both originals.

* fix(core): dedupe the wet/dry mix math between delayFeedback and chorusLfo

Both effect builders set wet.gain to the mix and dry.gain to its complement
in identical two-line blocks; fallow kept re-flagging it as a 10-line clone
on every unrelated change. Extracted setWetDryMix.

* fix(core): remove the build-audio-fx-runtime.ts stray resurrected by a main merge

An earlier merge with main brought this deleted file back (git's merge/delete
handling on an unchanged-on-one-side file); package.json already points at
build-inline-artifact.ts, so it sat unreachable and duplicating that file's
config, both of which fallow flagged.

* fix(studio): pull TimelineLanes under the 600-line cap

TimelineLanes.tsx hit 620 lines. Extracted the three per-clip pointer
gestures (resize-start, pointer-down move-arm, click/razor-split) into
createClipGestureHandlers — one factory call per rendered clip instead of
~120 lines of inline handler bodies in the render loop. 529 lines now.

* fix(studio): split the extracted pointerdown handler under the CRAP threshold

Moving the ~120-line gesture logic into timelineClipGestureHandlers.ts
concentrated it into two functions fallow flagged (onPointerDown at CRAP
63.6, onResizeStart at 31.6). Split the decision logic (which gesture a
pointerdown implies) into a pure resolvePointerDownAction, then split
its own intent-blocking check into isIntentBlocked. onResizeStart's guard
moved into canStartResize. Every function now scores under 30.

* fix(studio): drop the unused DomEditSelection import in PropertyPanelFlat

CI caught it on PR #3026 (wa-12-panel-params); a later refactor in the
stack removed the last use of the type here without removing the import.

* fix(studio): close the typecheck and fallow gaps wa-18b-reschedule opened

useAutomationLanes.ts's write() assumed gesture-scoped coalescing and a
preview-only commit that useDomEditAttributeCommits.ts never grew — backported
that option support from its own later commit so the two sides of the API
agree. The paste path and its tests were missing the box selection's v0/v1
bounds a sibling commit added to AutomationSelection. The FX panel's carve
controls still edited the six mechanism numbers (maxCutDb, bands,
intelligibilityBias) after carveProfile() collapsed authoring to one Strength
knob, so those fields no longer existed on HfCarveSettings; UI now edits
strength, and analyseCarveBands is called with carveProfile(strength).

Also closes fallow's complexity, dead-code and duplication findings on this
PR's diff: extracted automationLaneDragMath.ts (pure group/point-move math)
and useAutomationRangeDrag.ts (the marquee-select gesture) out of
useAutomationLaneGestures.ts, pulled a couple of render-loop ternaries and a
resolver into named functions, dropped an export nothing outside its file
used, and shared a step-simplifier between audioCarve's two envelope
builders.

The edge-stretch vs. box-select priority test in TimelineAutomationLane.test
was still pinning the pre-box-select rule (edge wins over a point sitting on
it) that a sibling commit deliberately reversed — a point inside the box is
now selected content, so grabbing it drags the group instead. Updated the
test to the shipped rule instead of the old one.

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

* fix(core): cap the via conic's weight so an edge-clamped via point can't NaN

A via point pulled out past the segment (viaX: 5, viaY: -3) clamps to
(0.999, 0.001) — exactly on the steady region's edge, where edge - viaX is 0.
viaConic divided by that zero to get an infinite weight, and shapeVia turned
Infinity into NaN a few steps later (Infinity - Infinity in the quadratic
coefficient). NaN reaching setValueCurveAtTime silences the automated
parameter for the rest of the render.

Capped the weight at 1e6 instead of leaving it unbounded — past that point
the arc already reads as touching the via point, so nothing visible is lost.
Also hardened shapeVia's existing denominator guard (`<= 0`) to `!(> 0)`,
since NaN fails the original comparison and fell through it.

Review by Miga (PR #3208).

* fix(studio-server): fingerprint the proactive waveform cache key too

The route already keys the waveform cache on the asset's size and mtime as
well as its path, so a rebuilt-in-place file gets fresh peaks instead of
stale ones. generateWaveformCache — the proactive path that runs on upload —
still called buildWaveformCacheKey with the path alone, so it wrote to a
different key than the route reads from (making the pre-generated cache
never found) and kept the exact collision bug this fingerprint exists to fix
on its own path.

Review by Miga (PR #3211).

* style(docs): run oxfmt on the /hyperframes-audio skill docs

Table column widths had drifted out of alignment with oxfmt's own rules,
failing format:check and blocking the Preflight gate every downstream
branch inherits. Whitespace only, no content change.

* fix(studio): widen PropertyPanel's resetModules render timeout again

The 20s margin (already once widened for the same reason) is timing out in
CI's full-monorepo Test run — the resetModules()+fresh-import render this
test needs is uncached and competes with every other package's test suite
for the same worker pool, and the same test passes in well under 2s
standalone. Went to 45s rather than re-tuning to whatever number happens to
clear the current CI load, since that number moves every time CI gains a
package.

* feat(core): carve against every voice over a bed, always (#3212)

* feat(core): carve against every voice over a bed, always dynamically

A bed usually runs under a whole sequence — a narrator, an interview answer, a
second presenter — and carving against one of them left the others fighting it.
`source` becomes `sources`, and `mixCarveSources` sums every voice onto the BED's
clock before anything is measured. That is what keeps one analysis sufficient: the
chain is fixed, so there is no per-voice filter to switch between, and bands drawn
from all the speech there is with envelopes that rise wherever any of it happens
answer the actual question — where and when is speech masking this bed.

Summed rather than averaged: two people talking at once mask more than either
alone. Audio before the bed starts is dropped rather than folded in at zero, since
it plays over nothing and shifting it would put a cut where there is no voice.

`dynamic` is gone. A fixed depth thins the bed through every pause, and once both
have been heard there is no reason to want it, so every carve follows the speech.

Two helpers the panel and the headless script now share instead of each carrying a
copy — two definitions of "what does this name suggest" drift, and then the two
disagree about which track is the voice:

- `classifyAudioName` reads a track's kind from its id and filename together.
  `unknown` is deliberately common: treating an unrecognised name as "not a voice"
  would hide the one track somebody needs to pick.
- `clipsOverlap` keeps out a voice that never plays while the bed does. An unwritten
  duration counts as unbounded, not zero — refusing a clip whose length the
  composition leaves to the media would drop the commonest case there is.

Files written before this still load: a single `source` reads as a one-voice list,
a stored `dynamic` is ignored, and an absent attribute means the defaults whole.

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

* fix(core): stop \b from missing underscore-separated names, guard clipsOverlap's negative duration

\b treats `_` as a word character, so \bbed\b never matched bed_01,
music_bed_loop, or theme_song, and \bvo\b/\bvox\b/\btts\b had the same gap —
an underscore-separated bed classified as "unknown" and could end up offered
as its own carve source. Replaced the short hints with a boundary that
actually excludes letters and digits on both sides.

clipsOverlap computed end = start + duration without guarding sign, so a
negative duration put end before start — an interval that does not describe
anything, and one specific case showed it silently dropping a real overlap
(a shorter, earlier broken end rejected a clip that genuinely contained the
point). Duration clamps to zero instead: a clip cannot un-play time, and a
zero-length clip at its start is the sane reading of "duration nobody wrote
down as positive."

Review by Miga (PR #3212).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(studio): port the carve UI off the removed source/dynamic fields

#3212 (accidentally squash-merged into this branch instead of main) changed
HfCarveSettings from a single `source` + `dynamic` toggle to a `sources`
list with dynamic mode removed outright — the multi-voice UI consumer that
goes with that shape lands in the very next PR, so this branch was left with
a type that no longer matched its own code.

Minimal port, not the multi-voice redesign that PR does properly: the
"Listen to" picker and analyse() treat sources[0] as the one voice this UI
still understands, and every dynamic-mode branch (the automated envelope
lanes, the toggle, the checkbox) is gone along with the field — a carve is
now always the static value the analysis computes, matching what the type
change made permanent. Test suite trimmed the same way: the automation-lane
and toggle tests covered behavior that no longer exists.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:16:36 -07:00
Miguel Ángel 08934bfd55 revert(cli): keep HeyGen API traffic on stable prod (#3202)
* Revert "fix(cli): route HeyGen API calls through canary (#3201)"

This reverts commit 5545521556.

* fix(cli): remove remaining EF canary routes
2026-08-10 21:41:21 -04:00
Miguel Ángel 5545521556 fix(cli): route HeyGen API calls through canary (#3201) 2026-08-10 21:26:46 -04:00
Miguel Ángel db3de4c1bf fix(cli): follow every redirect a host may answer with (#3148)
* fix(cli): follow every redirect a host may answer with

downloadFile handled 301 and 302 and passed the Location header straight back as a request target. Hosts answer with relative locations far more often than that assumed, and 303, 307 and 308 are all reachable, so a CDN handoff failed on a URL that was never a URL.

Locations now resolve against the URL that sent them, the code set covers all five, and a hop cap ends a redirect loop rather than recursing.

Keeps mains idle-response test, which asserts the request timeout fires and clears the partial file. An earlier version of this branch replaced the file wholesale and lost it, leaving downloadFile with no test that calls it at all.

* fix(cli): bound and isolate model downloads
2026-08-09 21:49:43 -07:00
Miguel Ángel b6ff3ab745 fix: preserve the composition query and serve the runtime before author scripts (#3114)
* fix(player): stop re-encoding the composition query

Every src the player sets goes through withShaderQueryParams, which parsed
the author's whole query with URLSearchParams and re-serialised it with
toString(). That is a form encoder: it writes a space as +, while callers
percent-encode and read back with decodeURIComponent. Those two codecs are
not inverses, so any space in any query value arrived corrupted.

It ran even when there was nothing to inject. With no shader attributes
both params are deleted, so the round-trip was pure loss, on every src,
for every consumer.

Append the two params to the raw query instead of re-serialising it. The
player now hands a composition its query back byte-identical.

Empirically space was the only casualty: plus, ampersand, equals, hash,
percent, question mark, quotes and non-ASCII all survived a URLSearchParams
round-trip. That is narrow, but a space in a headline or in SVG path data
is the common case, and invalid path data renders nothing at all.

Latent until now: no shipped consumer depended on query preservation, so
this surfaced only once compositions began carrying variable payloads.

* fix(cli): serve the runtime ahead of every author script

injectRuntime appended its script before </body>, so it landed after any
inline script the composition carried. At the moment a composition's own
script ran, window.__hyperframes was undefined and getVariables() was
unreachable: our documented API did not exist at the point authors are
told to call it.

Served order was gsap at line 6, the composition's init script at 20, the
runtime at 37. A probe inside the composition's IIFE recorded
hfTypeAtInit undefined with no variable keys, and the element rendered
its hardcoded fallback rather than the declared value.

The runtime is designed to load early. Its entry assigns __timelines,
installs the authored-opacity capture (whose own comment says it must run
while the document is still parsing), and exposes __hyperframes
synchronously, deferring real work to DOMContentLoaded. End-of-body
injection defeated all three, and nothing in it needs a parsed DOM, so no
defer is wanted.

Injects at head start instead, reusing the placement cascade
injectScriptsAtHeadStart already implemented rather than adding a fourth
copy of it. Head start rather than the closing tag so the runtime also
precedes author scripts inside head.

injectRuntime has exactly one consumer, the play server's composition
route. Every other surface reaches the runtime through the bundler, which
already injects into head, or deliberately serves raw.

Two registry blocks had independently worked around this by parsing the
authored attribute themselves. Those stay, but the workaround is no
longer the only way to read a variable at init.
2026-08-08 13:07:10 -07:00
James Russo ed3ff98ce0 fix(cli): stop skills update deleting skills the manifest never covered (#3118)
`hyperframes skills update` deleted skills that the same command had just
installed, from every agent directory on the machine, and reported them as
"no longer published".

`skills add --skill '*'` installs every skill in the repo — including the
repo-native ones under `.claude/skills/` and `.agents/skills/` — and the
upstream lock attributes all of them to `heygen-com/hyperframes`. The published
manifest is generated from `<repoRoot>/skills` only (gen-skills-manifest.ts), so
it never lists those. detectRemoved read that silence as "removed upstream" and
pruned them, so `check || update` could not converge: `add` reinstalled them and
the next `update` deleted them again.

Scope removed-detection to skills the manifest is actually authoritative for,
using the lock's `skillPath` — the only field that separates a skill installed
from `skills/` from one installed out of the same repo's other skill roots
(`source` is identical for both). An entry with no `skillPath` is treated as not
covered: this is a delete path, so unknown provenance fails safe.

Also resolve the prune's manifest canonically. Its notion of "still published"
could otherwise come from any `skills-manifest.json` within 16 parent
directories of cwd, which — since HyperFrames' own manifest declares
`source: heygen-com/hyperframes` — matches lock attribution and drives deletion.
The install-side check already did this (#2176); the deleting path did not, and
the comment claiming that was deliberate and "tested separately" had no such
test. An explicit `--source` still wins.

Verified end to end against the real CLI in a sandboxed HOME. Before: `add`
installed 25 skills, `update` printed "Removing 6 skill(s) no longer published:
captions-overlay, changelog-video, cut-the-curve, motion-doctrine,
oversized-cursor, seam-craft" and deleted all six (27 dirs -> 21). After: no
removal line, 27 -> 27. Both new regression tests fail on the pre-fix source.

Fixes #3111
2026-08-08 12:43:53 -07:00
Miguel Ángel dd629697d3 fix(cli): harden publish retry behavior 2026-08-07 15:13:04 +00:00
Miguel Ángel f2d6ce3245 fix(cli): recover transient publish failures 2026-08-07 14:53:20 +00:00
Vance IngallsandClaude Opus 5 47564ab94c fix(cli,core,lint,producer): terminate ffprobe options at every call site
#2740 added `--` to one of nine independent ffprobe invocations, so the
bug class it closed stayed open everywhere else while CI reported it
fixed — the regression test asserts the argv of that single site.

Reproduced on ffprobe 8.1.1: an asset named `-intro.mp4` probes fine
through extractMediaMetadata but fails with "Missing argument for option
'intro.mp4'" in audio pad/trim (mid-render), `hyperframes init`, whisper
duration probing and webmAlphaCheck. hevcPreviewLint catches and returns
false, so a dash-prefixed HEVC preview silently passes the lint rule.

Terminated at all of them:
  producer/services/render/audioPadTrim.ts (x2)
  producer/plan-parity-analysis.ts
  cli/commands/init.ts
  cli/utils/webmAlphaCheck.ts
  cli/whisper/transcribe.ts (x2)
  core/mediaGradeAnalyzer.ts
  lint/hevcPreviewLint.ts

audioPadTrim's runFfprobeJson is a near-verbatim clone of the engine's
runFfprobe and structurally cannot add the terminator itself, because
callers bake the input path into `args`. It now asserts the terminator
is present rather than letting a dash-prefixed path through, takes the
same stdio ["ignore", ...] as the engine helper, and redacts its stderr
— it was throwing raw ffprobe output, which echoes the input path, into
logs and telemetry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 02:23:34 -07:00
Miguel Ángel 4e7fcf7f2a fix(core): resolve sub-composition sibling asset paths everywhere (#2994)
Extends the studio-preview fix to the render path and the asset-discovery
utilities, which share the same resolver and had the same defect.

`rewriteAssetPath` takes an optional `assetExists` probe. A plain relative ref
authored in a sub-composition (`_shared.css`, `clip.mp4`) is re-pointed at the
composition's own directory when that sibling exists on disk; project-root refs
with no sibling (the registry's `assets/logo.png` convention) stay as authored.
Callers that can see the filesystem supply the probe, so the module stays free
of node:fs.

Also fixes a second defect in the inliner: `<head>` <link> hrefs and external
script srcs are hoisted into the root document but never went through the
rewrite at all, so even the documented `../` form escaped the project and 404'd
at render time.

Wired into the preview bundler, the producer compiler, the studio preview
builder, the HEVC preview lint, the project lint's asset scans, publish proxy
baking, and media-treatment source resolution.
2026-08-03 21:13:35 -07:00
James Russo 3a6b7f0612 fix: align local WebGPU capture behavior (#2907)
* fix: align local WebGPU capture behavior

* fix: address WebGPU capture review feedback

* fix: retain overlapping GPU seek work

* fix: satisfy runtime seek completion types

* fix: drain concurrent GPU seek work

* fix: prevent WebGPU capture barrier starvation

* fix: keep WebGPU presentation active during render seeks
2026-07-30 21:52:30 -07:00
Miguel Ángel fdc5932897 fix(cli): honor check navigation timeout (#2860)
* fix(cli): honor check navigation timeout

* test(cli): clarify diagnostic timeout precedence
2026-07-29 20:50:20 +02:00