When the probed video stream duration is inflated (container duration
fallback includes a longer audio track), FFmpeg delivers far fewer
frames than expected — typically exactly 50% for stock clips whose
audio is 2x the video length. The coverage gate then aborts the
render because it sees 50% coverage against the inflated expectation.
After extraction, if FFmpeg delivered <=60% of the predicted frames,
correct `metadata.videoStreamDurationSeconds` to the actual video
extent. This reconciliation feeds through to the coverage check via
the same `metadata` reference, so `expectedFramesForVideo` sees the
true duration without any interface changes.
This is a corrective complement to the preventive nb_frames
cross-check in extractMediaMetadata — it catches cases where
nb_frames is unavailable (MKV, live streams, fragmented MP4).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(gcp-cloud-run): run the Cloud Run image on Bun 1.4.2 so remote downloads cannot hang
* fix(engine): make the download deadline abort the body pipeline
* fix(engine): classify a single-keyframe video as problematic, not healthy
analyzeKeyframeIntervals short-circuited to isProblematic:false whenever
it found fewer than 2 keyframes, but exactly one keyframe is the worst
case for seek performance, not a healthy result: every seek past frame 0
must decode the entire file.
The single-keyframe branch treats the rest of the video stream's own
duration (not a container duration, which a longer audio track can
inflate) as one uninterrupted GOP. ffprobe's frame timestamps are
absolute presentation timestamps rather than relative to stream start,
so a re-muxed or trimmed clip with a nonzero start time is normalized
the same way extractFinalVideoFrameTimestamp already does for the same
quirk. The cache key now includes both values, matching that sibling
function's convention, since a stale cache entry from a different
duration/start pair would otherwise win silently.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* refactor(engine): share stream-window normalization between keyframe check and final-frame lookup
extractFinalVideoFrameTimestamp and analyzeKeyframeIntervals each
independently computed the same stream-start fallback, finite guard, and
cache-key format from the same metadata shape. Extracted resolveStreamWindow
so the two can't drift out of sync with each other.
Also: match ffprobe's real trailing-comma csv output in one keyframe mock,
add a test at the exact 2s threshold boundary, and comment the
noUncheckedIndexedAccess-only fallback at the single-keyframe branch.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* style(engine): trim long comments to one-to-three lines
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): stop the preview server's browser when the server exits
Cancel in-flight renders and thumbnail launches before draining the
browser pool on shutdown, instead of only closing whatever browser was
already registered. A render whose Chrome died from the shutdown signal
itself was being misclassified as a transient failure and retried with a
fresh, untracked browser that outlived the process. Reject new render and
thumbnail requests once shutdown has begun, and await an in-flight
thumbnail launch before closing it.
* fix(cli): close preview browsers before a hung render, keep SIGINT armed
shutdown() awaited renders before closing browsers, so a render slower
than preview.ts 3s exit watchdog left Chrome running when it fired.
Close the thumbnail browser and drain the pool concurrently with, not
after, the render wait, and bound the wait under that watchdog.
A second Ctrl+C/SIGTERM during shutdown removed the one-shot signal
handlers, so it hit the OS default and killed the process before
cleanup ran. Use persistent handlers guarded by the existing
shuttingDown flag instead.
Also: getThumbnailBrowser could still hand a live lease to a request
that lands after shuttingDown flips true; trim a comment over budget;
replace a fixed-sleep test race with a drain-signal barrier.
* fix(engine): make browser pool shutdown terminal, not just draining
drain() resets its drainPromise to null once it settles, so acquire()
only waits for an in-flight drain -- a render still unwinding after
shutdown could relaunch Chrome the instant that drain resolved
(probeStage.ts:449-465 has exactly this gap between an abort check
and a later acquireBrowser call). No non-shutdown caller reuses the
pool after draining it (checked every drainBrowserPool()/drain()
call site), but added a separate terminal close() rather than
changing drain()'s own semantics, so a future reuse caller stays
safe by default.
BrowserLeasePool.close() sets a permanent closed flag before
draining, and acquire() checks it both before and after its one
await point, so a request already mid-await when close() lands still
sees it once that await resolves. studioServer's shutdown() now
calls the new closeBrowserPool() instead of drainBrowserPool().
Also bounds drain()'s own wait: a close() that hangs past 1s now
gets escalated to a force-close instead of blocking the caller
indefinitely, keeping total shutdown time under preview.ts's 3s exit
watchdog alongside the existing render-wait bound.
* fix(engine): trim closeBrowserPool JSDoc to house comment length
* fix(server): contain static file requests within their served roots
Static asset handlers joined the raw request path onto their root
directory without checking where it landed. Hono decodes percent
escapes while routing, so an encoded backslash (%5c) arrives as a real
separator on Windows and dot segments arrive already collapsed —
"/assets/..%5c..%5csecret" and "/%2e%2e%2fsecret" could read files
above the studio bundle root (embedded Studio server) and above
compiledDir/projectDir (engine render file server). Browsers never
request those shapes, but anything that can reach the bound port can:
raw local clients, LAN peers when the preview binds 0.0.0.0, and a
shared-machine process hitting the render server.
Both handlers now resolve the request path first and refuse anything
that collapses outside its root, using the same lexical containment
the preview asset route already applies — bundle/project assets behind
symlinked directories keep being served, only dot-segment escapes are
rejected. Route-level pathSafety tests pin the behavior in both
packages, including the Windows backslash shape and the
symlinked-asset cases from the descriptor-pinning regressions.
Validated: new containment suites fail on the prior handlers (200 +
marker file content served from outside the root) and pass with the
guards; cli server (140), engine services (1024+, matching the main
baseline incl. pre-existing browserManager failures), typecheck,
oxlint, oxfmt and the full workspace build all clean.
* fix: close fallow audit findings, prove containment cross-platform
Fallow audit (health/complexity): createFileServer's request handler
exceeded the CRAP-score threshold once the new containment checks were
added. Extracted resolveContainedPaths, a small pure function that
resolves and validates both roots, cutting the handler's own branching
back down.
Fallow audit (duplication): the new pathSafety test files copied their
temp-root/studioDir setup and teardown from the existing staticAssets
test wholesale. Extracted the shared parts into
studioServerTestFixture.ts (makeStudioServerRoot, writeStudioIndexHtml,
cleanupStudioServerRoot); only the per-file vi.mock("node:path", ...)
call stays duplicated, since Vitest requires it declared statically in
each test file and it cannot be moved behind a shared function -- that
one block keeps a narrow, targeted ignore comment instead of a
file-wide one.
Security claim: isWithinProjectRoot's %5c-decoded-backslash escape is
real, but only on Windows -- POSIX never treats a literal backslash as
a separator, and Hono leaves %2f undecoded in c.req.path, so none of
the new tests' encoded-traversal payloads produce an actual escape on
Linux (verified empirically: they pass identically with the fix
reverted). isWithinProjectRoot now takes an optional path-module
override (defaulting to the host's native node:path, so every existing
caller is unchanged) so its win32 and posix branches can be unit
tested directly from one OS, the same pattern already used by
producer/fileServer.ts's isPathInside. New tests cover a backslash
traversal, a mixed backslash/forward-slash traversal, and a
drive-letter path escaping the root under path.win32, plus the
existing posix case -- confirmed each one fails when the check is
removed. The HTTP-level tests stay as-is: real regression coverage for
them comes from the windows-latest CI job, which runs both new
pathSafety suites unfiltered.
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: orphan4183 <kingenmaww@gmail.com>
* feat(studio): typed audio values, clip fades, audible video drops
Audio rows in the media inspector (Volume, Speed, Media start) take a
typed value: click the readout, type "-6 dB", "1.5x" or "2.5s", Enter.
Speed presets are hidden on <audio> clips.
Clip-edge fades: data-fade-in / data-fade-out in seconds, honoured by the
preview runtime (per-tick gain), the render mixer (afade after volume) and
Studio (Fade in / Fade out rows, and drag dots at the top corners of audio
clips and audible videos). Linear on both sides so preview and render
agree; fades that outrun the clip scale to meet inside it.
Dropped videos with an audio stream now land with data-has-audio="true"
instead of muted, so the mixer keeps their sound. The media metadata
endpoint reports hasAudio from the ffprobe call it already makes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(audio): one owner for fades, dB, and afade-after-volume
Illegal fade values become no-fade at fadeGain; typed inspector input
still returns null so the readout stays red. Master and group mixes
share one filter chain with volume then afade. Fade-out stays anchored
to the trimmed clip end.
* fix(studio): share audio helpers and bound typed media values
* fix(studio): reserve sibling fade duration when editing
---------
Co-authored-by: Jake Moran <jakemoran2011@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix(engine): audio source-not-found names the file and shares one src resolver with lint
* fix(engine): audio source-not-found prints the resolved path with forward slashes on Windows
* fix(parsers): the shared src resolver ignores a media fragment like the browser
* feat(engine): render speed ramps with the same source-time mapping as the preview
The frame extractor, frame lookup table, coverage check, compile-time duration and audio mixer
read a clip's rate lane through sourceTimeAt, so a rendered frame at t shows the source frame
the preview shows. Audio bakes a ramp as consecutive source slices stretched by their mean
rate, keeping pitch. The compile-time attribute reader now handles single-quoted and
entity-encoded JSON attributes. Adds a speed-ramp regression fixture.
* test(producer): regenerate the speed-ramp golden audio with the current engine
The stored baseline predates the per-slice audio fix, so its audio drifted 1 to 3 percent in
pitch from the expected source-time track and the CI render correlated at 0.61. Rendered in the
CI test image, the new baseline tracks the expected pitch to 0.1 percent median, and the compare
now reports visual 0 failed frames and audio correlation 1.000.
* refactor(core): drop data-preserve-pitch, pitch is preserved by construction
The engine always time-stretches audio with atempo, so an opt-out read only by
the preview made preview and render disagree. Remove the attribute and its
reader so both paths keep pitch.
* feat(core): speed ramps as a rate lane in data-automation
A clip can carry a rate lane in data-automation. sourceTimeAt integrates it to source time and is the one mapping the runtime uses for media seeking, playback rate, natural duration, child timelines and the audio-master clock. Rate is clamped to 0.1-10 in one place, pitch is preserved unless data-preserve-pitch is false, and ramped clips play through the media element. Lint warns on a rate lane outside video and audio.
* fix(core): format the speed-ramp files and expect the unified rate clamp in the engine tests
* test(engine): read the playback-rate clamp from core instead of a literal
* feat(cli): hyperframes timeline prints a project's tracks and clips
* docs(cli): list the timeline command
* feat(cli): timeline command reports unreadable lanes; add skill docs
* fix(cli): timeline stays inside the project, shares one start cache, keeps small volumes
* fix(cli): timeline resolves symlinks before the project containment check
* fix(cli): timeline accepts project folders whose name starts with two dots
* refactor(cli): timeline and compositions share the engine's start resolver
* docs(skills): point agents at the timeline command where they decide how to read a project
* fix(cli): timeline prints absolute main-timeline times and the owning file for nested clips
Nested rows carry absStart/absEnd on the main timeline and their owning
file, alongside existing local start/end. Text prints absolute time first,
local time in parentheses. Adds jq/node one-liners for common queries,
tested against a fixture.
* fix(cli): timeline places nested media at the runtime start and tests its query one-liners
A nested media clip with a negative literal data-start was reported later than it plays,
because the raw start was clamped before the host offset was added. The skill's jq and node
one-liners now run against the fixture in the test suite.
* fix(cli): timeline clamps a negative media start to 0 when its host starts at 0
The runtime skips the host-offset formula for a host at t=0 and clamps the start, so the
timeline command now does the same.
* refactor(core): one media-start rule shared by the runtime and the timeline command
The runtime and the CLI each carried a copy of the rule for when a nested media clip's
start takes the host offset, and the copies had drifted. Both now call
resolveMediaStartSeconds.
* test(cli): timeline fixture for a media start written as an expression
parseNumeric and the runtime's parseStrictFiniteTimingNumber are the same function, so the
two callers cannot disagree on what counts as a literal; this pins the expression case.
The streaming stage's peer-death test replays the contract "hook first, with
the original CaptureFailure, then the peers abort and the pool rejects with
its own summary" inside its mock, and nothing on the engine side pinned it.
The gate now lives in createPoolFailureHandler, tested without a browser:
the hook sees the original failure before the peer signal flips, a hook
that throws can no longer skip the abort (the pool still reports the
classified failure, not the hook's error), failures after the first are
ignored, and a cancellation leaves the peers running. Mutation-checked:
removing the try/finally, swapping the order, or dropping the first-failure
guard each fails one test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On the disk path a transient worker death is deliberately non-fatal: the
orchestrator's adaptive retry re-captures that worker's missing frames. The
streaming path has no per-worker retry and the dead worker's frames are
gone, yet it inherited the same gating — so its peers parked in the ordered
writer waiting for a frame that would never come, and sixty seconds later
the no-progress watchdog relabelled the death as a stall. That is the
`stuck at k/N` field signature.
isPoolFatalWorkerFailure splits the two: every non-cancelled failure ends a
streaming pool, and a new onWorkerFailure hook delivers the ORIGINAL failure
to the caller before peers are aborted, so the ordered writer can be released
with it rather than with a stall. Mutation-checked in both directions,
including that `cancelled` never propagates (the pool is already aborting).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
EPIPE (darwin/linux), EOF (win32's UV_EOF for a closed named pipe) and
ECONNRESET all mean the process reading our stdin has gone. Bucketed as
authoring, telemetry blamed the composition for a host event. Matched on
the errno code and on the message, since the code is lost when a worker
pool flattens the error to a string.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The field "write EPIPE" / "write EOF" render errors are this path:
writeFrame waits on once(stdin, "drain") for back-pressure to clear, ffmpeg
dies first, and Node rejects the drain wait with the stream's own error.
That rejection escaped writeFrame as the render error, so ffmpeg's exit code
and stderr — captured, but read only from the `false`-return path — were
discarded. With N workers feeding one encoder the writer is parked most of
the time, which is why the parallel cohort reads EPIPE where a single-worker
render reads "Streaming encoder exited before frame N: FFmpeg exited with
code …".
Wait for the exit to settle, then report it as the close race does. Both
halves are mutation-checked: rethrowing again fails the test, and returning
"exit" without awaiting settlement fails the synchronous getExitStatus read.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Segmented capture needs the same concat-demuxer stream copy the chunked
encoder already does. Behaviour preserved, including the
external_interruption classification the caller maps to a retryable
failure: the helper reports it rather than swallowing it. The concat list
keeps living beside its inputs, because concurrent encodes share an output
directory but not a chunk directory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SystemInfo.getProcessInfo is served only by the browser target; a
page-target session rejects with "is only supported on the browser
target". The sampler swallows probe failures by design, so this shipped as
a permanently empty reading: a 10 s debug render reported chromeMemory
null with capturePath already correct. Against the browser target the same
render reports browser 105 MB / renderer 359 MB over 3 samples.
readChromePids is extracted so the target choice is pinned by a test whose
fake rejects a page session the way Chrome does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Start a 2 s unref'd sampler when a session finishes initializing, stop it
in closeCaptureSession, and report peaks on CapturePerfSummary. Pids come
from puppeteer (browser) and CDP SystemInfo.getProcessInfo (renderer, GPU),
so the long-render "Target closed" class can be correlated with Chrome
memory instead of guessed at. Disable with HF_CHROME_MEMORY_SAMPLER=false;
cadence HF_CHROME_MEMORY_SAMPLE_MS.
The start hook is finalizeSessionInit rather than the tail of
initializeSession: that helper is already the single owner of "session is
ready" across both of initializeSession's exits, so the early screenshot
return cannot silently skip sampling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With a boolean reentrancy flag, the Task 3 close sequence
(`stop(); await sampleOnce()`) returned immediately whenever an interval
tick was still awaiting CDP/ps, so getCapturePerfSummary read stats from up
to one interval earlier and onSample fired once more after the session had
closed. Reproduced with a 350 ms getPids on a 100 ms interval: close
resolved with samples=0 and the callback landed afterwards.
The guard now keeps the in-flight promise and returns it to any caller, so
an interval tick still never stacks a second round-trip (the existing
reentrancy test holds) while a close-time call resolves only after the
running tick has merged. Deviates from the plan's reference body, which
used `let inFlight = false`; the ChromeMemorySampler interface and all
names are unchanged, only the sampleOnce() contract is stronger.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three independent reviewers found that deleting timer.unref() in
createChromeMemorySampler left the suite green: the plan's Global Constraint
(fire-and-forget on an unref'd interval) had no failing test. The new test
captures the real NodeJS.Timeout through the setIntervalFn seam and asserts
hasRef() is false after start(); it goes red when the unref() call is
removed.
The seam could not be used without a cast as typed in the plan
(`typeof setInterval`): with the DOM lib in scope that type also carries the
`(handler, timeout) => number` overload, which a capturing wrapper cannot
satisfy. setIntervalFn / clearIntervalFn keep their names but are now typed
to the two-argument shape the sampler actually calls; the global timers are
assignable to those shapes, so the defaults are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pure service for Phase -1 of the long-form render plan: merges per-pid RSS
rows into browser/renderer peaks, last-sample total and GPU presence on an
unref'd interval with a reentrancy guard, so a CDP or ps failure can never
reach the capture loop. CaptureOptions.onMemorySample is the live route the
producer will wire so a mid-render crash still reports memory state. Nothing
instantiates the sampler yet (Task 3); no behaviour change.
Beyond the plan's tests, pins the double-start, empty-sample and catch
guards directly, since the interval-based error test stays green when the
catch is removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`readonly string[][]` parses as `ReadonlyArray<string[]>`, so `calls.push`
was a TS2339 in 5214552cd. It slipped through because the engine tsconfig
excludes `src/**/*.test.ts` and vitest strips types; caught by running tsc
on the test file explicitly. `Array<readonly string[]>` is what the
neighbouring "one ps call" test already uses.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every render image (packages/cli/src/docker/Dockerfile.render,
packages/gcp-cloud-run/Dockerfile, examples/k8s-jobs/Dockerfile.example,
Dockerfile.test) is node:22-bookworm-slim with no procps, so `ps` is ENOENT
in containers and sampleProcessRss would return [] on every tick: the whole
cloud fleet would report chrome_memory_samples: 0, indistinguishable from
"no Chrome pids". Linux now reads VmRSS from /proc/<pid>/status with a
per-pid try/catch, so one renderer that exited between discovery and
sampling omits only itself (with `ps` a wholly-dead batch fails the call).
No child process per tick on the primary server platform. Same precedent as
the CLI's orphanCleanup.ts / processTree.ts. `ps` stays for darwin and
other POSIX; `tasklist` for win32. The reader is an optional fourth
parameter with a default, so Task 2/3 callers are unaffected.
Scope note: spec section 2.5 records the Target-closed population as
docker=false throughout, so this closes a render_complete completeness gap
on cloud, not a coverage gap on the crash class this phase targets.
Verification: 15/15 vitest; four linux-branch mutants (per-pid catch
rethrows, regex loses /m, kb <= 0 weakened, path /status -> /stat) each
fail at least one test, restored green. Real-process smoke check on darwin:
sampleProcessRss([process.pid, <exited pid>]) returns one sample for the
live pid (BSD ps exits 0 with partial output when a valid-range pid is
gone). The linux path is unit-tested against a /proc/<pid>/status fixture
only; no Linux host or VM was available in this session to execute it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review of 76d8e2682 found three surviving mutants: removing the
`Number.isInteger(p) && p > 0` filter, dropping the `new Set` dedup, and
weakening `kb <= 0` to `kb < 0` all passed the plan's 8 tests. The filter is
the guard that matters most: Task 2 feeds `browser.process()?.pid`, and a
single NaN or undefined reaching `ps -p` makes ps exit non-zero, which the
never-rejects catch turns into `[]` for every pid in the sample.
Mutation check: each of the three mutants now fails exactly one test
("drops invalid pids and duplicates before calling ps" for the filter and
dedup, "returns [] when the memory column is not a positive number" for the
tasklist guard); restored source is 10/10 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
First piece of the long-form render Phase -1 telemetry: the Chrome memory
sampler needs resident set size per pid without adding pidusage or
systeminformation. `ps -o pid=,rss=` covers POSIX in one call for every pid;
`tasklist /FO CSV` covers win32 one pid at a time. The function never rejects
and omits pids that exited between discovery and sampling, so a failed sample
can never reach the capture loop.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(capture): never silently render a webgpu composition blank
A composition declaring data-requires-webgpu only got the Chrome WebGPU
flag when hardware GPU auto-detection succeeded. On a software-GPU host
it launched without the flag and rendered blank with no error.
compositionRequiresWebGpu (static declaration check) and
assertWebGpuAdapterAvailable (live per-page adapter probe) now live once
in packages/engine's browserManager, the shared choke point every launch
site already goes through. All six launch sites (frame capture, the four
CLI diagnostic commands, the studio thumbnail server, and the render
pipeline's capture-options builder) route through those two functions;
the old CLI-only static-heuristic guard is deleted, not kept alongside
the new one. A software host that cannot obtain any WebGPU adapter now
fails loudly naming the requirement instead of producing a blank frame.
* fix(capture): thread requiresWebGpu through the distributed chunk-render path
The distributed renderChunk() path built its CaptureOptions without
requiresWebGpu, so it fell through to frameCapture's own fetch-based
detection, whose failure path silently treats "requires webgpu" as
"doesn't" if that fetch ever fails. Every other launch site (frame
capture, all four CLI diagnostic commands, the studio thumbnail server,
and the in-process render pipeline) already reads the value from HTML
already in memory or on disk instead of relying on that fallback.
Reads the compiled composition's index.html the same way probeStage
already does and derives requiresWebGpu from it before building
CaptureOptions. Adds a regression test that pins the exact path
renderChunk() reads at render time, since the composition doesn't
actually exercise WebGPU so an end-to-end render can't distinguish the
two cases on this host.
* test(producer): observe the real CaptureOptions renderChunk() builds
The previous regression test called plan() and compositionRequiresWebGpu()
directly, bypassing renderChunk() entirely, so it could not detect a
regression to the wiring it was meant to protect (confirmed: it stayed
green with the fix reverted).
Exports the existing createCaptureSession dependency seam so a test can
substitute a spy that delegates to the real implementation while
capturing the CaptureOptions object renderChunk() actually constructs.
Verified non-vacuous by reverting the fix and confirming the test fails.
* fix(gcp-cloud-run): resolve puppeteer-core from a real file in the webgpu probe
The image build's WebGPU adapter probe imported puppeteer-core from an
inline `bun -e` eval string. The eval's synthetic /app/[eval] location
can't resolve the workspace-hoisted dependency, so every build of this
stage failed closed with "Cannot find package 'puppeteer-core'" before
the probe itself ever ran.
Moves the probe into a real script file next to probe-beginframe.ts,
which already imports puppeteer-core dynamically the same way and
resolves fine from its real workspace location. Verified locally against
a cached chrome-headless-shell binary.
* fix(engine): bound the webgpu adapter probe so a stuck driver can't hang the caller
requestAdapter() has no native timeout. A broken software GPU driver
that never settles it would hang assertWebGpuAdapterAvailable
indefinitely, including inside the GCP image build's probe RUN step,
producing an opaque build timeout instead of a clean probe failure.
Races the in-page request against a bounded timeout so a stuck adapter
reads as unavailable and fails the same way a genuinely absent one does.
* test(cli): mock the engine's webgpu exports in the browser-fallback test
* test(producer): run the typegpu adapter fixture only on hosts with a hardware WebGPU adapter
The fixture declares data-requires-webgpu, so a software-only host now refuses it
instead of silently rendering the shader blank. The old golden had no shader in it.
Tag it webgpu, skip that tag in the CI image and the test scripts, and re-record the
golden from a Metal render with the shader visible.
* fix(engine): bound the composition fetch that decides the webgpu flag
createCaptureSession fetched the served page with no timeout before launching the
browser, so a hung server stalled a render forever and hung a fake-timer test. Give it a
five second limit, and let that test say it has no WebGPU requirement the way the
producer does.
* fix(capture): refuse a software fallback WebGPU adapter instead of rendering on it
swiftshader reports a WebGPU adapter but cannot render canvas WebGPU, so a
data-requires-webgpu composition on a software-only host crashed on its first frame.
Refuse fallback adapters with the same clear WebGPU-unavailable error. The GCP image
probe now serves its page over http (file:// hides navigator.gpu) and reports a
software-only host instead of failing the image build.
* fix(engine): parse the composition root tag without a backtracking regex
* fix(engine): keep quoted attribute values whole when reading the composition root tag