* chore(docker): pin FFmpeg 8.0.1 in the Docker images and host CI
Install one pinned static BtbN build (n8.0.1-66, month-end release) in
Dockerfile.test, the CLI render image, the Cloud Run image and the k8s
example, and bump the host CI install-ffmpeg-linux action from 7.1.3 to
the same build. Regression baselines and the shipped images now use the
same FFmpeg version instead of Debian's apt ffmpeg 5.1.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(producer): regenerate six regression baselines on FFmpeg 8.0.1
The Docker test image now renders with FFmpeg 8.0.1 instead of Debian's
5.1. Six fixtures fail against the 5.1 baselines; everything else passes
unchanged, including vfr-screen-recording.
- style-2-prod, style-10-prod: FFmpeg 8 honors the MP3 gapless header,
so the probed SFX duration is 1.0s instead of 1.044898s. Regenerate
output.mp4 and compiled.html.
- vignelli-stacking: one checkpoint (8.06s) now shows source frame 201
of the 25fps a-roll, which is the right frame for that time; the old
baseline showed frame 202.
- missing-host-comp-id, variables-prod, speed-ramp: AAC encoder output
differs enough to drop the RMS-envelope correlation on flat signals
below 0.9. Sample correlation against the source stays at 0.9999.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): read the ffmpeg version from n-prefixed release builds
Release-branch ffmpeg builds, including the BtbN build now pinned in CI,
print "ffmpeg version n8.0.1-66-...". extractMajorVersion skipped the
"n8" token and returned 0 (and 1 for n7.1.3), so the CLI telemetry
smoke check failed. parseToolVersion fell back to the whole banner line.
Accept an optional "n" before the version number in both.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): keep the preflight comment share flat
Fold the n-prefix note into the existing doc line instead of adding new
comment lines, which the comment ratchet rejects.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(docker): pick the FFmpeg build per arch and fail on a bad binary
Dockerfile.test, the k8s example and the Cloud Run image always extracted the
amd64 archive on multiarch bases, and the piped version check passed even when
the binary could not run. All four images now pick linux64 or linuxarm64 by
TARGETARCH, fail on any other arch, and grep both ffmpeg and ffprobe for the
pinned version.
style-2-prod and style-10-prod compiled.html go back to the previous golden
with only the SFX data-duration/data-end changed, instead of a full
recompile that also picked up unrelated compiler output.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(cli): bound checks of non-finishing compositions
* fix(cli): account for cold startup in deadline witness
* fix(cli): wait for profile owners before test cleanup
* test(cli): separate portable deadline and Linux process witnesses
* test(cli): preserve per-launch browser profile ownership
* fix(cli): start check deadline after browser readiness
* fix(cli): the browser download unpacks on machines without an unzip command
* fix(cli): a corrupt browser download on the yauzl path is retried and named
* revert(cli): drop the cause-chain reader; @puppeteer/browsers never passes the cause through
* fix(cli): snapshot, check, layout and thumbnails honour software WebGPU like render
* test(cli): stub the local WebGPU helper in the studio server tests
* fix(engine): webgpu compositions are recognised after the bundled runtime script
* fix(engine): a hung GPU probe falls back to software in 15 s instead of 120 s
* fix(engine): the GPU probe keeps the configured launch timeout and bounds only its page
* build(parsers): declare the html-attribute-spans subpath in its descriptor
* build(gcp-cloud-run): the BeginFrame contract stage copies parsers, which the engine now imports
* test(cli): the screenshot launch pin follows the shared local WebGPU helper
* fix(engine): the GPU probe's wait for Chrome's first page falls inside its 15 s limit
* fix(cli): name the unzip errors when every Windows unzip tool fails
* fix(cli): a cached empty or folder Chrome counts as missing, not found
* fix(engine): skip an empty cached Chrome the same way the CLI does
* fix(engine): keep scanning the Chrome cache past a build it cannot read
* test(engine): a folder at the cached Chrome path is skipped
* fix(cli): browser ensure fails when the unzip leaves no Chrome executable
* refactor(cli): keep install debug output and report only the last unzip attempt
* fix(cli): browser ensure exits after a failure or a --force download
* fix(cli): browser checks name why Chrome could not be installed
* fix(cli): render shows why its setup step failed instead of a crash dump
* refactor(cli): build the browser download label and staged test path once
* refactor(cli): one owner for the render setup result line
* fix(cli): render setup failures report through one error line
* refactor(cli): one reader and writer for both render setup lines
* fix(cli): close every browser on SIGTERM and SIGINT through one launch owner
* fix(cli): own SIGTERM before the GPU probe browser launches
* fix(cli): keep tests on the managed GPU mode and drop the engine signal flag
* test(cli): give the browser fallback fake a process handle
* test(cli): signal the GPU probe case only once the signal owner is installed
* test(cli): mark the launching case only after the signal owner is installed
* fix(cli): stop browser --force from deleting chrome under running renders
* style(cli): format browser manager test
* fix(cli): sweep leftovers of killed browser installs and on browser clear
* docs(cli): state what browser clear returns without history wording
* fix(cli): route FFmpeg exec-failure hint through getFFmpegInstallHint()
checkFFmpeg()'s "cannot start" branch (binary found but fails to run
-version) hardcoded a Windows/DLL-flavored install hint on every
platform instead of delegating to the already platform-aware
getFFmpegInstallHint() used by every other branch in this file.
* test(cli): pin the ffmpeg cannot-start hint through the async probe mock
* fix(cli): keep the runtime-DLL cannot-start hint on Windows
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix(cli): a Studio render refuses with the browser check's own message
* test(cli): the Studio browser refusal test no longer loads the real producer
* 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
The test polled a pid file written by `echo $$ > path`, whose shell
redirection creates (truncates) the file before the echo writes to
it. A read landing in that window parsed an empty string to 0, which
satisfied "not undefined" and exited the wait loop early, failing
the next assertion with "expected 0 to be greater than 0" — the
exact shape of the intermittent CI failure. The loop now only
accepts a parsed value that's a positive integer, reproduced with a
busy-wait harness that hit the empty-file race in most iterations
and confirmed clean after the fix.
* feat(telemetry): add output-shape request facts to render events
output_resolution_preset, output_format, hdr_mode, video_frame_format,
and gif_fps_capped join render_complete/render_error, all threaded
from already-resolved RenderOptions/RenderPlan fields rather than
recomputed: createRenderPlan already computes gifFpsCapped (clamping a
requested --fps above 30 to 30 for --format gif) but only used it for
a console warning; this threads that same decision into RenderOptions
and telemetry. All five are known before the pipeline starts, so both
events carry them even when a render fails before a perfSummary
exists.
Also wires output_format/output_resolution_preset into Studio's
separate emitStudioRenderComplete/emitStudioRenderError path, since
Studio's request genuinely varies those two per-request. hdr_mode,
video_frame_format, and gif_fps_capped stay CLI-only: Studio exposes
no HDR-mode, video-frame-format, or GIF output control to users at
all, so there is no per-request value to report for those three.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): add adapters_used to render events
adapters_used lists the runtime deterministic adapters
(packages/core/src/runtime/adapters/) a composition exercises.
Investigated the actual codebase rather than the ticket's guessed
7-adapter list and found 12 real registered adapters (gsap, css,
waapi, animejs, lottie, three, typegpu, d3, leaflet, mapbox, maplibre,
google-maps) — the map/data-source adapters share the exact same
window.__hf<Name> registration mechanism and are equally real
"adapters used" facts, so all 12 are included.
Detection unions a live page.evaluate probe (piggybacked on the same
probeSession resolveCompositionElementCount already uses) with a
static regex fallback over the compiled HTML, since this is purely
observational with no gating role (unlike compositionElementCount) —
union strictly increases recall rather than needing one source of
truth. Threaded through the same plumbing chain as the already-merged
composition_element_tags/aroll_video_count/heygen_video_count.
Studio's emitStudioRenderComplete/emitStudioRenderError get this field
automatically, with no Studio-side code change, via the same shared
capture-observability spread heygen_video_count already uses.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): add audio/image/sub-composition/color-grading counts
audio_count, image_count, sub_composition_count, audio_group_count,
color_grading_count, and has_lut join composition_element_tags in the
same single scanElementTags pass — audio/image/audio-group counts read
from the scan's uncapped tag map so a 50+-distinct-tag composition
can't push a named count into the "other" bucket. sub_composition_count
counts data-composition-src mounts directly rather than reusing
collectSubCompositionSrcs, which dedupes by src and drops
placeholder/remote mounts (a different, smaller fact than the mount
count this field reports).
has_lut decodes the HTML-entity-escaped form of data-color-grading
before parsing, since linkedom's serializer re-emits it
"-escaped on every compile-pipeline round-trip — without the
decode this silently reported false for every LUT-graded composition.
The LUT-presence check now mirrors normalizeLut's own definition of
"has a LUT" exactly, closing a gap where a blank or empty lut value
would have reported true.
Threaded through the same plumbing chain as the already-merged
per-tag/provenance/adapter fields; Studio's emitter picks these up
automatically via the shared capture-observability spread.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): add browser_version_major and ffmpeg_version_major
Both are already-run subprocess calls whose output was discarded:
readToolVersion (checkFFmpeg's preflight) already runs `ffmpeg
-version`, and chromeLaunchOutcome already runs `--version` as a
Chrome launch sanity probe on every render. A shared
extractMajorVersion helper pulls the first X.Y-shaped number from
either banner, threaded onto EnvironmentCheckOutcome/
EnvironmentCheckResult, then RenderOptions right after
runEnvironmentChecks resolves in the local render path only.
Docker renders never populate these two RenderOptions fields (the
containerized producer runs its own preflight the host CLI never
sees), so both properties come back absent there rather than
reporting the host's own toolchain versions for a render that didn't
use them.
Studio does not call runEnvironmentChecks or this ffmpeg/Chrome
preflight at all before creating a render job, so its render_complete/
render_error events will not carry these two properties unless
Studio's own startup path is separately instrumented — a genuine
parity gap, not an oversight in this change.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): add authoring_skill_source and authoring_skill_invalid
Both derive from resolution logic createRenderPlan already runs:
authoring_skill_source names whichever step actually won attribution
(an explicit --skill flag, or the skill persisted in hyperframes.json)
using the same flagSkill/projectConfigSkill values already computed
for authoringSkill itself, not a second lookup. authoring_skill_invalid
surfaces the raw --skill value when it fails slug-shape validation
(normalizeSkillSlug rejects malformed input, not unregistered skill
names) — this value already exists as RenderPlan.invalidAuthoringSkill
and already drives a CLI warning in present.ts; this change threads
the same field into telemetry rather than adding a parallel one.
Threaded through both RenderOptions builders in execute.ts (single and
batch render) and all three telemetry call sites in render.ts,
including the Docker path — skill attribution is a request-level fact
independent of which backend executes the render.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): add root_body_mismatch and root_body_delta_px_bucket
Measures the composition root's authored data-width/data-height
against the scaffold's own html/body CSS size, straight from the
compiled document string scanElementTags already scans — the
source-level fact, not runtime DOM state. init.ts's applyCompositionSizing
(the #4001 fix) only patches the live DOM after the browser loads this
same HTML, so this measurement stays meaningful whether or not that fix
ran for a given render.
Reuses @hyperframes/parsers' canvasScaffoldPatterns regexes (the same
ones packages/lint's root_dimensions_mismatch rule reads) for the CSS
side. Root-tag detection here is a lighter first-data-composition-id-match
heuristic, not lint's more thorough findRootTag (which also handles a
leading decorative <svg> defs block) — that helper isn't part of
@hyperframes/lint's public API, and expanding it is out of scope here.
The delta is bucketed (0 / 1-10 / 11-50 / 51+ px) rather than reported
raw, and both fields come back absent — not a false/"0" default — when
the root or the CSS block can't be read at all.
Threaded through the same plumbing as every other static-scan field
this week, including the live-capture-observability fallback used on a
crash that never reaches perfSummary.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* feat(telemetry): add hf_env_overrides (names only, capped at 20)
Reports the sorted names of HF_*/HYPERFRAMES_* env vars present when
createRenderPlan resolves — never their values, since several of these
carry filesystem paths. Snapshotting at plan-resolution time (the very
first line of the render command, before any pipeline code runs)
matters: this render's own preflight later injects
HYPERFRAMES_FFMPEG_PATH/HYPERFRAMES_FFPROBE_PATH into its own
process.env, which would otherwise make those two always read back as
"operator overrides" whether or not the operator ever set them.
Reports an empty array, never an absent key, when nothing is set,
matching every other capped/bucketed field added this week. Capped at
20 names, documented alongside the cap constant.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli): stop clobbering studio render_complete fields, fix an env leak
trackRenderComplete's explicit audio_count/image_count/root_body_mismatch/
adapters_used/etc. keys, sourced from perfSummary.drawElement and only ever
populated on the CLI's own render.ts path, always won the object spread over
the observability-capture fallback those same keys share with trackRenderError,
even when the direct value was undefined. studioRenderTelemetry.ts's
emitStudioRenderComplete never populates drawElement, only the capture
fallback, so 24 fields silently came back missing from every studio-triggered
render_complete event despite Studio having computed and sent the capture
value. Each field now falls back to its captureXxx counterpart via a new
directOrCapture() helper when the direct field is absent, with regression
tests proving both the fallback and that the direct value still wins when
both are present.
Also excludes HF_SHADER_WORKER_ENTRY from hf_env_overrides: cli.ts sets this
on every invocation to point the bundled shader worker pool at its own file,
sharing the HF_ prefix by coincidence rather than because an operator set it.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* style: remove em and en dashes from added code comments
No behavior change. Rewrites 22 comment lines this PR's own diff introduced,
using a comma or a period where the dash separated two clauses.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* test(producer): add a temporary diagnostic for a CI-only rootBodyMismatch failure
Not a real fix. The rootBodyMismatch test group fails deterministically in
CI (confirmed on two runs of the same head) but will not reproduce locally
by any method tried, including an isolated file run, the exact combined
producer:test:unit command, a worker-count-constrained run, and a fresh
clone with a clean install and build matching CI's own steps.
This dumps the intermediate regex-match state for the simplest failing
fixture to stderr so the next CI run's log says what actually differs.
To be removed once that answer lands.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* test(producer): widen the rootBodyMismatch diagnostic to compare import paths
Still not a real fix. The previous diagnostic (ca4e73983) proved the
imported HTML_BODY_CSS_WIDTH_FIRST_RE has 4 capture groups in the failing
CI run instead of the 2 the source and production code expect, but it used
a dynamic import(), which can resolve through a different codepath than
renderOrchestrator.ts's static import. This adds a static import of the
same binding, a require.resolve() of the resolved file path, and a direct
read of that file's own regex line, so the next run can rule in or out a
static/dynamic resolution split versus a genuinely different file on disk.
Locally both imports agree (2 groups, same object, correct file). To be
removed once the CI answer lands.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(producer): read the merged 4-group scaffold-size regex correctly
main merged #4012 while this branch was in flight, consolidating
HTML_BODY_CSS_WIDTH_FIRST_RE/HEIGHT_FIRST_RE from 2 capture groups (digits
only) to 4 (prefix text, digits, suffix text, digits) so cli's
applyResolutionPreset could reuse the same regex for its in-place replace.
detectRootBodySizeMismatch still read groups 1/2 as the digits, now the
prefix/suffix text, so Number() on them was NaN and every render's
rootBodyMismatch/rootBodyDeltaPxBucket silently came back undefined. Fixed
to read groups 2/4, matching lint's core.ts readHtmlBodyCssSize exactly.
Removes the temporary diagnostic test (ca4e73983, e2e384c8e) now that its
CI log gave the real answer: both a static and dynamic import of the
regex resolved to the same file, and that file's own content had 4 groups
because this branch's merge-base predates #4012, not because of any
resolution or caching difference. A fresh CI run confirmed no CI-only
failure remains after merging main and fixing the indices.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* chore: nudge CI (no workflow run triggered on previous push)
* chore: nudge CI again now that #4017 is out of draft
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
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>
Picks up crbug 522872457's fix (CL 8032671), which landed after the
152.0.7935.0 canary cut and so was absent from the old 152.0.7928.2 pin.
Re-probed every 3D signal the compile gate matches, drawElementImage vs a
CDP screenshot of the identical state, on the shipping headless-shell
binary. PSNR, old pin -> new pin:
backface-visibility:hidden 1.4 dB -> 14.8 dB still DAMAGED
preserve-3d (no backface) 46.7 dB -> 46.7 dB clean
perspective() 45.2 dB -> 45.2 dB clean
matrix3d() 45.2 dB -> 45.2 dB clean
rotate3d() 45.3 dB -> 45.3 dB clean
translateZ under perspective 29.9 dB -> 29.9 dB marginal
The upstream fix repaired the collateral damage only: dropped sibling
content and lost backgrounds now render, but a culled backface is still
painted. So the 3D gate stays. Beta rather than Canary because
153.0.8000.0 measured identical on every variant.
Follow-up filed as PRINFRA-486: four of the five signals the gate matches
were never broken on any build tested, so it may be able to narrow to
backface-visibility alone. Needs a corpus eval first — this probe covers
static angles only, and animated 3D subtrees take a different path.
* fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on Windows chrome-headless-shell launch crashes
Field feedback (#hyperframes-cli-feedback ts=1784116246, win32/x64, CLI 0.7.58) hit
`Failed to launch the browser process ... Code: 3221225595` with no stderr. Exit
code 3221225595 = 0xC0000409 = STATUS_STACK_BUFFER_OVERRUN, a Windows stack-
corruption fatal from the pinned chrome-headless-shell binary. The reporter
recovered by pointing HYPERFRAMES_BROWSER_PATH at system Chrome; render then
used the screenshot fallback and produced the MP4 cleanly.
The generic "Try --docker" hint the CLI already emits didn't name that env var,
so the workaround was undiscoverable. Add a Windows-scoped launch-crash
remediation sibling to `chromeLaunchRemediation` (Linux, `linuxDeps.ts`) and
`wrapDownloadFailureWithBrowserPathHint` (download-time, `manager.ts` — #2443).
Fresh concrete case for the #2078 lineage (closed with explicit invite to
resubmit on a concrete case).
- New `packages/cli/src/browser/windowsCrash.ts` — `isWindowsChromeCrashError`
gates on Puppeteer's `Failed to launch the browser process` wrapper AND the
specific crash code (decimal `3221225595`, hex `0xC0000409`, or symbol
`STATUS_STACK_BUFFER_OVERRUN`), so unrelated Windows launch failures don't
mis-fire this hint. `windowsChromeCrashRemediation` returns the actionable
block scoped to win32.
- `render.ts` `handleRenderError` calls it after the existing
`chromeLaunchRemediation` (Linux) check; both fall through to the generic
errorBox if neither matches.
- Tests: 9 vitest cases covering positive matches on all three code forms,
negative on Linux-shared-lib launch failures, negative on the code alone
without the launch wrapper, and off-platform / non-launch short-circuits.
— Via
* fix(cli): fail the Windows crash branch through failCommand, not process.exit
`scripts/check-cli-process-ownership.mjs` AST-walks every non-test file
under `packages/cli/src` (bar `cli.ts`) and forbids direct process
termination — only the CLI entrypoint owns exit. The new Windows
chrome-headless-shell arm called `process.exit(1)` while both sibling arms
(Linux shared-lib, macOS) and the generic fallback call `failCommand()`,
so the required Lint job failed on that line and preview-regression failed
downstream of its preflight.
`failCommand()` carries the central failure-hook wiring, so this is the
behaviour the branch already wanted.
Exporting without FFmpeg installed used to show "Server error (503). Check
the terminal for details." The server already knew the exact cause and sent
a per-platform install command in the response body; Studio discarded that
body and printed the status code. The user found out only after the
composition was finished.
Studio now asks the dev server on load whether this machine can encode, and
the Renders panel shows the cause plus a copyable install command when it
cannot, with a Recheck that avoids restarting Studio.
- New GET /api/environment/ffmpeg calls runEnvironmentChecks() with every
optional check off, which is exactly the FFmpeg and ffprobe pair `doctor`
runs, so Studio and the CLI cannot disagree. Only a passing result is
cached.
- The refusal lives in startRender, not in a button. Studio renders from
three places (the panel's Export, the header's, and each composition card
in the sidebar), so a per-button check would leave the others free to
queue a render that cannot finish. The header and sidebar controls reveal
the prompt rather than going dead.
- A null probe result means "no answer", not "missing", so an older or
unreachable dev server cannot lock a working setup.
- Failed render responses now surface the server's { error, hint }.
- getFFmpegInstallCommand() is the single owner of platform-to-command, with
the prose hint derived from it. Windows gains a winget command and keeps
the manual download route.
Accessibility: the prompt's explanatory line measured 2.2:1 on the card's
amber background against a 4.5:1 minimum, because the panel's usual grey for
secondary text does not survive the tint. Now 6.6:1. Keyboard focus was
invisible on all three controls and now matches the panel's focus ring.
Also folds in cleanups the repo's gates required: the Renders tab moves out
of StudioRightPanel (it was at the 600-line cap and every field it needed was
already on the shell context), StudioContextInput stops keeping a second copy
of the renderQueue shape, and the server tests share one temp-project helper.
The cli, engine, and lint packages each carried their own copy of the
ffmpeg/ffprobe lookup, annotated fallow-ignore code-duplication, and the
copies had drifted: the engine copy handled Windows PATHEXT and executed
which/where without a shell but lacked the Homebrew-dirs fallback for
GUI-spawned processes; the cli copy had the opposite. One resolver in
@hyperframes/parsers (the dependency-graph bottom) now carries the union
of both hardenings, and all three packages delegate to it. Every
consumer gets strictly more robust resolution; env-override semantics
per call site are preserved via configuredMustExist.
Field feedback (#hyperframes-cli-feedback ts=1784227832, darwin/x64,
macOS 12, HyperFrames CLI 0.7.60) hit
`dyld: Symbol not found: _kVTCompressionPropertyKey_ReferenceBufferCount`
from VideoToolbox when launching the pinned chrome-headless-shell
mac-152.0.7928.2. The symbol is macOS-13-only, so older hosts abort
the binary at dyld load before any browser process starts.
The reporter recovered by installing an older shell
(`@puppeteer/browsers install chrome-headless-shell@150`) and pointing
`PRODUCER_HEADLESS_SHELL_PATH` at it. Their check/snapshot commands
accepted that older cached shell (they do not force the pinned build),
but the render command requires v152 via `preferManagedChrome: true`
and could not fall back on its own. The generic "Try --docker" hint
didn't name any of the browser-path env vars.
Sibling failure mode to the download-time hint added in #2443 and the
closed-with-invite #2078 (SIGTRAP at launch on macOS arm64), and the
in-flight #2481 (Windows STATUS_STACK_BUFFER_OVERRUN); same
`HYPERFRAMES_BROWSER_PATH` remediation, different trigger + platform.
The match is gated on:
1. Puppeteer launch-failure wrapper text
2. dyld Symbol-not-found signal
3. a macOS-13-only symbol OR the VideoToolbox framework
so unrelated darwin launch failures do not mis-fire the hint. The
symbol name is macOS-version-specific by construction — if a user's
dyld cannot find `_kVTCompressionPropertyKey_ReferenceBufferCount`
their host is <13, no separate `os.release()` gate needed.
- Signed-off-by: Via -
`hyperframes render` picks up `PRODUCER_HEADLESS_SHELL_PATH` (engine
per-worker launches read it directly, and `render.ts` even propagates
the CLI-resolved executable path into it as a courtesy). But
`hyperframes check` / `snapshot` / `compare` / `grade-compare` all
route through `openSettledCompositionPage` → `ensureBrowser` →
`findFromEnv`, and `findFromEnv` only knew the CLI-native name
`HYPERFRAMES_BROWSER_PATH`.
Field report — #hyperframes-cli-feedback ts=1784095034 (win32/x64,
CLI 0.7.58): the cached `chrome-headless-shell 152.0.7928.2` crashed
with `Failed to launch the browser process: Code: 3221225595`
(`STATUS_STACK_BUFFER_OVERRUN`). Setting `PRODUCER_HEADLESS_SHELL_PATH`
to system Chrome unblocked `render`, but `check` still crashed on the
broken cached shell because it never read that env var.
Docs and deployment manifests (`skills/hyperframes-animation/adapters/
typegpu.md`, `packages/gcp-cloud-run/Dockerfile`, `examples/k8s-jobs/
Dockerfile.example`) all instruct users to set
`PRODUCER_HEADLESS_SHELL_PATH`, so the escape hatch is
documentation-blessed but was silently half-implemented on the CLI
side.
Alias it in `findFromEnv`. Tiebreak matches `render.ts:1479` —
`HYPERFRAMES_BROWSER_PATH` wins when both are set.
This is the CLI side of the symmetry #2459 is closing on the engine
(engine gaining `HYPERFRAMES_BROWSER_PATH` honoring); the two make the
alias coherent both directions.
- Sibling to #2443 (surfaces `HYPERFRAMES_BROWSER_PATH` on download
failures).
- Not the same class as #2040 (arm64 pin), #2078 (SIGTRAP), or #2082
(launch crash rewrap) — those are download / launch fixes; this is
the env-var alias gap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Miga review nit on #2443. Existing test only covered darwin/arm64 by
default; parameterize via it.each across process.platform so each platform's
Chrome-path hint from browserPathHintForPlatform is asserted in the rethrown
error message.
— Via
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When `@puppeteer/browsers`' install() rejected for any reason the corrupt-
archive recovery path couldn't handle (`All providers failed for
chrome-headless-shell <ver>`, DNS/network, macOS Gatekeeper, a second
corruption), the raw error propagated with no mention of the escape
hatch. Wrap the surfaced error to name `HYPERFRAMES_BROWSER_PATH` with a
platform-specific example (macOS/Windows/Linux) and keep the original
via `cause`.
Field feedback ts 1784055194.202169 in #hyperframes-cli-feedback
(darwin/arm64, HF CLI 0.7.57) hit this and recovered by pointing the
env var at system Google Chrome — they discovered the workaround on
their own because the error didn't tell them.
Download-time sibling of #2078 (SIGTRAP at launch, same remediation,
different trigger).
— Via
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adopt the fake-timer pattern the sibling "recovers when a crashed reclaimer
leaves both lock directories" test in the same file already uses. Without
fake timers, if the dynamic `import("./manager.js")` beat between
`installFsMocks({ initialMtimeMs: Date.now() })` and the `withInstallLock`
call exceeds `staleMs` (50 ms on a busy shared runner with `vi.resetModules()`
per beforeEach), the new immediate-stale short-circuit added in #2328 fires
on iteration 1, breaks out before `waitedMs` reaches `waitNoticeMs=20`, and
no "Waiting for another hyperframes process" warn ever emits — the assertion
at `manager.test.ts:428` (`expected false to be true`) then fails.
Under fake timers, `Date.now()` is frozen at the mtime seed, so the lock
stays non-stale across the dynamic import and the wait-notice branch
observes real polling; `vi.advanceTimersByTimeAsync(staleMs + pollMs * 5)`
then drives the loop past both the wait-notice threshold and the stale
deadline so the reclaim + acquisition still resolves.
Test-only change; no production-code diff. Verified 27/27 in
`packages/cli/src/browser/manager.test.ts` under `vitest run`.
* fix(cli): bump @puppeteer/browsers to ^3.0.6 to fix render hang on node >=24.16
`hyperframes render` (and `browser ensure --force`) hangs forever during
Chrome provisioning on Node >= 24.16 (repro'd on macOS arm64 / Node 26.5.0;
fine on Node 22). Root cause is a transitive extractor bug, not our logic:
@puppeteer/browsers@2.13.x install()
-> extract-zip@2.0.1 -> yauzl@2.10.0
A classic-stream backpressure regression (nodejs/node#63487, works 24.15,
breaks 24.16+) surfaces a latent fd-slicer destroy() bug in yauzl 2.x
(yauzl#169). The inflate read stream stalls partway through the first entry
large enough to cross the write highWaterMark (chrome-headless-shell's
1.86MB LICENSE.headless_shell, stalls at ~1.31MB), never emits `end`, so
stream.pipeline never settles and extraction busy-spins. The half-extracted
cache has no executable, so every later render re-enters
"Cached binary missing -> re-download" and hangs again (puppeteer#14957).
Fix: @puppeteer/browsers 3.0.2 dropped extract-zip/yauzl entirely (now uses
modern-tar). Verified 3.0.6 extracts chrome-headless-shell cleanly under
Node 26.5.0 and keeps the full API manager.ts uses (install,
getInstalledBrowsers, Cache, computeExecutablePath, detectBrowserPlatform,
Browser) with an identical on-disk cache layout. Cross-platform (the same
.zip/yauzl path affected Linux + Windows too).
Adds a regression guard asserting the pin stays on the extractor-free
major (>= 3) and never reintroduces extract-zip/yauzl.
Fixes#2103
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(cli): clarify extractor-guard wording — yauzl is an optional peer, not dropped entirely
Review note on #2104: @puppeteer/browsers 3.0.6 keeps yauzl as an optional
peer fallback (default extractor is modern-tar), so the regression-guard
comment + it-text shouldn't say it was 'dropped entirely'. Test assertions
(extract-zip + yauzl absent from `dependencies`) unchanged and correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the highest-severity max-effort code-review finding on the now-
merged #2082 (the drawElement Chrome-version-pin fix): findFromHyperframesCache
matched a cached Chrome by browser type only, never comparing its
buildId against CHROME_VERSION. Any machine that already rendered with
an older hyperframes version has an old build (this pin has moved
131 -> 151 -> 152 across releases) sitting in ~/.cache/hyperframes/chrome,
which satisfied the lookup and silently defeated the whole point of
#2082's version bump for exactly the population it was meant to fix —
drawElement's new capability probe would then permanently and silently
fall back to screenshot capture instead of ever fetching a build that
implements canvas.drawElementImage.
Verified directly (not just via review): seeded ~/.cache/hyperframes/chrome
with the old 131 build, confirmed a real render previously kept using it
forever; with this fix it's correctly ignored and 152 is downloaded.
New regression test locks in the buildId mismatch case.
Address PR #2082 review feedback:
- Route studio thumbnail + render call sites through preferManagedChrome
so studio renders no longer silently fall back to whatever system
Chrome happens to be installed.
- `hyperframes browser ensure` now resolves through the same
preferManagedChrome path render uses, so it reports what render will
actually pick instead of any system Chrome it happens to find.
- Point the unsupported-Chrome fallback log at `browser ensure --force`
instead of `doctor`, which doesn't check Chrome/drawElement capability.
- Fix stale findFromCache comment: the HF pin is now a Dev-channel build
that can be newer than a user's puppeteer-cache Stable install.
canvas.drawElementImage is an unlaunched Dev/Canary-only Blink feature
(~151+). The CLI's pinned CHROME_VERSION fallback was still 131.0.6778.85 —
a puppeteer 24→25.2.1 bump that pinned it to Chrome Dev 151.0.7912.0 was
written on 2026-06-29 but never merged (orphaned local commit, no PR). Any
render on that pin, or on the shared puppeteer-cache binary, or on system
Chrome (Stable, no drawElementImage at all) got a canvas.getContext("2d")
missing the method and crashed mid-capture with "ctx.drawElementImage is
not a function" instead of falling back (HF#2060).
Three changes:
- Bump puppeteer/puppeteer-core to ^25.2.1 across every package that
depends on it, and CHROME_VERSION to 152.0.7928.2 (today's Dev channel;
confirmed via direct probe to implement drawElementImage, unlike 131).
- `ensureBrowser({ preferManagedChrome: true })`, always used by `render`:
resolve straight to our pinned/cached build, skipping both the shared
puppeteer-cache preference and system Chrome. Rendering shouldn't depend
on whatever arbitrary Chrome a machine happens to have — that's exactly
how this regressed (any Mac with Chrome.app installed bypassed the CLI's
pin entirely).
- A runtime capability probe in the engine, right before any other
drawElement work: if `drawElementImage` isn't a function on the injected
canvas, route to the existing screenshot-fallback gate instead of
crashing. This is the real backstop — it protects every resolution path
(env override, stale cache entry, a future Chrome regression), not just
the ones `preferManagedChrome` reaches.
Verified end-to-end: rendering against chrome-headless-shell 131 (confirmed
to lack drawElementImage) now falls back cleanly and produces a valid MP4
instead of crashing; rendering against a capable build still engages
drawElement normally. 922 engine tests + 1373 CLI tests pass.
Fixes#2060.
A partially-downloaded or interrupted chrome-headless-shell archive left in the
cache makes @puppeteer/browsers' install() throw "invalid
end-of-central-directory" during extraction. That error propagated out of the
browser check and hard-blocked the render, forcing users onto the fallback
renderer until they manually cleared the cache — a recurring Windows failure.
Detect the corrupt-archive extraction error (isCorruptArchiveError), clear the
cache to drop the bad archive, and retry the download exactly once; non-corrupt
errors and a second corruption still propagate (no infinite retry). The pure
predicate and the recovery wrapper are unit-tested.
* fix(cli): purge stale/partial browser installs instead of wedging retries
Two independent reports of the same failure: a `chrome-headless-shell`
zip extraction gets interrupted (Windows AV lock, sleep/wake, ctrl-C)
and leaves only the alphabetically-early files (ABOUT/LICENSE) in the
target directory, no executable. Every subsequent `browser ensure` (or
implicit re-download from `findBrowser`/`ensureBrowser`) sees the
directory already exists and hands it straight to @puppeteer/browsers'
install(), which throws "folder exists but the executable is missing"
without re-extracting -- permanently wedging the machine until someone
manually deletes the directory. `--force` didn't help because it was a
phantom flag: `browser.ts` never declared it, so it silently did
nothing (mentioned only in an error-message string).
Root cause: `findFromCache()` already detects this exact case (dir
exists, exe missing) and returns it as `staleHyperframesCachePath`, but
`findBrowser()`/`ensureBrowser()` fed that straight into a re-download
without ever deleting the stale directory first, so install() hit the
same "exists" branch every time.
Fix:
- `findFromCache()` also returns `staleInstallPath` (InstalledBrowser's
`.path` -- the actual install-folder root, not the missing
executablePath) for the stale case.
- Both `findBrowser()` and `ensureBrowser()` now purge that directory
(`rmSync`, inside the existing `withInstallLock` mutex from #1866 so
a purge can't race a concurrent installer) before retrying, so
install() actually re-extracts instead of erroring.
- Wired up a real `--force` flag on `hyperframes browser ensure`: it
purges the whole HF-managed cache (reusing the already-tested
`clearBrowser()`) and skips every cache/system shortcut, so it always
gets a fresh download regardless of what's currently on disk --
matching what the existing (previously false) help text already
claimed it did.
Not fixed here (separate root cause, flagged for later): neither
report's machine had a usable auto-detected system Chrome fallback on
Windows -- `SYSTEM_CHROME_PATHS` only lists macOS/Linux paths, so
`findFromSystem()` can never succeed on win32. Both reporters worked
around this manually via HYPERFRAMES_BROWSER_PATH, which still works
fine; adding real Windows system-Chrome detection is a distinct,
larger change.
Test: extended manager.test.ts's existing stale-cache-redownload test
to include a populated stale install directory and assert it's gone
before the mocked install() is called (was previously only asserting
the redownload happened, not that the fix's purge step ran). Added a
new test for `ensureBrowser({force: true})` purging the cache and
bypassing a healthy cache/system-Chrome shortcut. Also fixed the shared
fs mock's `rmSync` to actually simulate recursive deletion (drop
nested tracked paths too), which the new tests need and the old ones
never exercised. Full CLI suite (1222 tests) passes.
* fix(cli): serialize force browser cache purge
* fix(cli): lock chrome-headless-shell install against concurrent extraction races
A detailed post-release feedback report of `render` producing a fully
black 15s MP4 despite lint/validate/inspect/snapshot all passing and
Studio preview playing correctly. Root cause traced by the reporter:
chrome-headless-shell had been manually re-extracted after `browser
ensure`'s own download got stuck mid-extraction when two concurrent
invocations raced on the same cache dir. The manual extraction lost a
macOS Gatekeeper/quarantine or GPU/Metal entitlement bit that a clean
install sets, so headless GPU frame capture silently returned all-black
frames — invisible to every existing health check, since they only
confirm the binary *exists*, not that it captures real pixels.
`--no-browser-gpu` fixed it completely, confirming the GPU-capture path
specifically. A related, vaguer report of the same race the prior loop
run ("'browser ensure' hung mid-extraction after a race from two
concurrent invocations") was deferred pending a clearer repro; this
report supplied one.
@puppeteer/browsers' install() has no concurrency guard of its own —
confirmed by reading its source: two concurrent installs for the same
browser/buildId both proceed straight to download+unpack with no
existing-install check, no lock. Two ensureBrowser()/findBrowser() calls
that both miss the cache at the same time (the common case on a fresh
machine, or right after `browser clear`) race on the same extract target.
Fix: mkdirSync as an atomic cross-process mutex around the download —
recursive:false makes it throw EEXIST when another process already holds
it (that's load-bearing: recursive:true would silently no-op instead).
Zero new dependencies. A concurrent caller polls until the lock releases,
then re-checks the cache before deciding whether to download at all — the
common case (loser waits, then reuses the winner's completed install)
never re-downloads. A lock held past a generous timeout is reclaimed
rather than left to wedge every future render if the holder crashed
mid-extraction. Applied to both call sites that reach the racy
downloadBrowser() (ensureBrowser's two paths, and findBrowser's stale-
cache re-download — the file already carries a code-duplication
suppression between these two near-identical functions).
Not doing (out of scope for this fix): the reporter's second suggestion,
a deeper `doctor` check that actually captures a test frame rather than
checking binary existence. That's a real gap but a separate, larger
feature — this fix prevents the corruption that caused it, which matters
more than detecting it after the fact.
Tests: two new cases (lock releases after a successful download; a lock
held past its timeout is reclaimed rather than hanging — exercised via
withInstallLock's injectable timeoutMs/pollMs with tiny real waits,
avoiding fake-timer mocking through the full async ensureBrowser call
graph). All 13 tests in manager.test.ts, 22 across packages/cli/src/browser,
and the full CLI suite (1115 tests) pass.
* test(cli): isolate browser install lock test from system chrome
* fix(cli): guard stale browser lock reclaim
WSL first-render success (34.7%) is dominated by a downloaded chrome-headless-shell that launches into `libnss3.so: cannot open shared object file` — doctor/preflight only checked the binary exists, never that it can load its libraries.
- New linuxDeps.ts: /etc/os-release distro detection (Debian/Fedora/Arch/Alpine) + WSL detection, per-distro Chrome dep set, ldd-based shared-lib probe.
- preflight.checkChrome downgrades a found-but-unlaunchable Chrome to a render-blocking error with the exact per-distro install command.
- Distro-aware ffmpeg hints; launch failures converted to actionable guidance pointing at `hyperframes doctor` (skipped on ARM64).
- Detect + print remediation (no auto-install).
Render-reliability workstream P1-4. Success measured on PostHog dashboard 1783183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>