Fixes#1847
The producer's render path stripped a sub-composition's authored root element and inlined only its children, so any CSS anchored on that root (its id or classes) matched nothing in the compiled HTML even though it resolved fine in Studio preview.
Changes:
- Wire flattenInnerRoot into the producer's sub-composition inliner (packages/producer/src/services/htmlCompiler.ts) so its render-time DOM shape matches the preview bundler's.
- Rewrite a bare root [data-composition-id="X"] box selector to a :has()/:not() pair that lands on exactly one of the host or the flattened wrapper (packages/core/src/compiler/compositionScoping.ts), avoiding double-applying additive properties like padding.
- Restore the composition's own id onto the flattened wrapper when the host has no id of its own, an "anonymous" host (packages/core/src/compiler/inlineSubCompositions.ts).
- Fix the runtime's startResolver to find a composition's start time through the post-inlining data-composition-file marker, not just data-composition-src or data-composition-id (packages/core/src/runtime/startResolver.ts).
Also adds regression coverage for the literal issue #1847 repro (a class, not just an id, on the authored root, styled via a descendant selector), a test proving the runtime compositionLoader's anonymous-host path doesn't share this bug, and fixes stale test documentation and a misattributed code comment surfaced during review.
Verified: 29-fixture Docker regression sweep on linux/amd64 (matching CI) run 3x clean, 967/967 core unit tests, full CI green.
At least 4 independent post-release feedback reports of a render completing
successfully (exit 0) with audio elements correctly authored and detected at
compile time (audioCount > 0), but the final MP4 having no audio track —
discovered only via ffprobe or manual playback, with the CLI giving no
indication anything went wrong. Users worked around it by muxing the
generated audio in manually with ffmpeg.
Root cause: runAudioStage sets hasAudio from processCompositionAudio's
success flag, but discarded its error field — the actual reason a per-element
audio prep step or the final mix failed (source not found, extract failed,
ffmpeg error) was computed and then thrown away. A real audio-mix failure was
therefore indistinguishable from "no audio was authored": both just produced
hasAudio: false with zero diagnostic output.
Thread the mixer's error through as audioError (only set when audios.length
> 0 but the mix failed) and log.warn it from both call sites (the main
render path in renderOrchestrator.ts and the distributed plan() path) so a
real failure is loud instead of silently downgrading to a video-only render.
Tests: 4 new cases for runAudioStage (mixer error surfaced, generic fallback
message when the mixer doesn't provide one, no audioError on success, no
audioError when there's no audio to mix). renderOrchestrator.test.ts (68
tests) unaffected. plan.test.ts's one failure (an audio-bearing planHash
determinism test timing out at 30s) is pre-existing — reproduces identically
on unmodified main with these changes stashed.
Follow-up to the render-reliability batch (#1841/#1842/#1843). Threads two capture-reliability counters through the existing observability → CLI-telemetry pipeline (no new PostHog wiring) so #1842's hardening is measurable on dashboard 1783183:
- transient-retry burn (CaptureAttemptSummary.reason gains "transient-retry"; counted into RenderCaptureObservability.transientRetries on BOTH the recovered and the still-failed paths via a shared helper).
- OOM classification (memoryExhaustionDetected set when describeMemoryExhaustion classifies the failure).
Surfaced as capture_transient_retries + capture_memory_exhaustion_detected render-event props. Tests cover the attempt tagging and the payload mapping.
Further follow-up (different subsystems): encoder-frame-0-exit signal, and P1-3 pre-flight-rejection / P1-4 cli_env_check counters.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Users pick an --resolution preset whose orientation/aspect ratio (or alpha/HDR mode) conflicts with the composition; the render fails deep in the compiler with a cryptic message. ~8K err / ~1K users.
- New shared pure helper checkOutputResolutionCompatibility in @hyperframes/parsers — single source of truth for aspect/alpha/HDR/downsample/non-integer-scale constraints; suggests the matching-orientation, tier-preserving preset.
- CLI render pre-flight aborts early (before browser/ffmpeg) with an actionable, fix-suggesting message; resolveDeviceScaleFactor delegates to the same helper for identical defense-in-depth messages.
- Suggest (not auto-select); defers when dims can't be determined rather than guessing.
- suggestMatchingPreset keys tier off the -4k suffix so square-family swaps (square + landscape-4k -> square-4k) aren't downgraded to HD.
- render.js DOM polyfill made a lazy import; render.test cold-import beforeAll hooks given a 30s timeout to absorb CI contention.
Render-reliability workstream P1-3. Success measured on PostHog dashboard 1783183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four independent capture-infra hardening changes for the P2-5 failure bucket (~15K err / ~7K users):
- protocolTimeout auto-scales by device-scaled output area (applied before probe launch, since it's immutable post ppt.launch()).
- Single bounded transient retry (MAX_TRANSIENT_CAPTURE_RETRIES=1) on Target closed / Page crashed in the parallel disk-capture path; abort short-circuits before retry.
- Narrow OOM classification (Set maximum size exceeded etc., disjoint from transient) → actionable guidance naming output dims.
- StreamingEncoder.getExitError() threads FFmpeg's real exit reason into frame-0 encoder-death errors.
Render-reliability workstream P2-5. Success measured on PostHog dashboard 1783183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions
The #1 render failure bucket in production telemetry (PostHog project 356858,
dashboard 1783183 "HyperFrames — Bottom-Line & Activation"; ~65-69K
occurrences / ~27-28K affected users over 30 days, ~80% via AI-agent
authoring flows) is a `data-composition-src` reference pointing at a scene
file that is empty, malformed, or missing.
Root cause, traced end-to-end:
- The literal error "Composition HTML is empty or could not be parsed: <path>"
is real (not a PostHog paraphrase) — thrown by a since-reverted guard in
packages/core/src/compiler/inlineSubCompositions.ts (#1364), then changed to
a silent skip in #1678 to avoid aborting renders on partial content during
authoring. #1629 added per-assembler guards for 3 skill workflows
(product-launch-video, faceless-explainer, pr-to-video), but general-video
and hand-authored flows — where the dominant filename `scene-title.html`
(40K+/68K of the bucket) originates — have no assembler and thus no guard.
#1678 assumed the assembler guards from #1629 covered this pre-render; they
only covered 3 of the many authoring flows.
- On current `main`, an empty/malformed data-composition-src file no longer
crashes or throws during render — it's silently dropped by the tolerant
inliner. Reproduced locally: `hyperframes render` on a project with an
empty scene-title.html "succeeds" after ~93s (two 45s
pollSubCompositionTimelines timeouts) with the scene silently missing from
the output video. `hyperframes validate` also reports "No console errors"
for the same broken project.
- The raw `Cannot destructure property 'firstElementChild' of
'documentElement' as it is null` crash reproduces directly against
linkedom (the DOMParser polyfill packages/cli/src/utils/dom.ts installs in
the real CLI runtime) for empty and non-HTML input — confirmed with a
standalone repro script, not just inferred. jsdom/happy-dom (used in this
repo's own test environment) are spec-compliant and never produce a null
documentElement, which is why this needed a linkedom-specific test file.
Fix:
- New shared helper `checkSubCompositionUsability`
(packages/core/src/compiler/subCompositionValidity.ts) is the single
source of truth for "is this data-composition-src file usable" — mirrors
the inliner's own parse/template/body logic so all callers agree.
- `inlineSubCompositions.ts` (preview/studio bundling) now uses the shared
helper internally but keeps its #1678 tolerant skip-and-continue behavior
unchanged — mid-authoring iteration on a partial project must keep
working. `onMissingComposition` now also receives a human-readable reason.
- New render-only pre-flight (`assertSubCompositionsUsable` in
packages/producer/src/services/htmlCompiler.ts) walks every
data-composition-src reference (including nested ones, root-relative,
matching parseSubCompositions' own resolution) before any compilation
work starts, and throws naming every offending file at once. This is
unconditional — not gated behind --strict — because a render that
silently drops a scene is strictly worse than one that refuses to start.
Confirmed locally: render now fails in ~0.4s with an actionable message
instead of "succeeding" after 93s with a missing scene.
- New `hyperframes lint` rule `missing_or_empty_sub_composition`
(packages/cli/src/utils/lintProject.ts) surfaces the same check as a
file-scoped, actionable lint error (already unconditional — lint exits 1
on any error).
- `hyperframes validate` now also runs this check before launching a
browser, so it no longer reports "No console errors" for a project with a
broken sub-composition.
- `packages/core/src/parsers/htmlParser.ts`: guarded every
`documentElement`-may-be-null access (parseHtml, updateElementInHtml,
addElementToHtml, removeElementFromHtml, extractCompositionMetadata,
validateCompositionHtml) with a new typed `CompositionHtmlParseError` (or,
for validateCompositionHtml's collect-and-report contract, a typed
validation failure) instead of a raw crash.
Tests: empty file, whitespace-only, malformed/non-HTML, missing file, nested
sub-compositions (both happy path and broken-grandchild), and the happy path
— at the shared-helper, lint, and render pre-flight layers.
Not changed: the AI-agent authoring skills (skills/*). general-video and
hand-authored flows have no assemble-index.mjs equivalent to guard, so the
fix is at the CLI/render layer instead — flow-agnostic, covers every
authoring path, and the skills' existing "run lint/validate and stop on
failure" guidance now actually catches this class of mistake once run.
Not run in this environment: the producer package's full regression-harness
test suite (`bun test` in packages/producer) — it performs heavy real
rendering (S3 asset downloads, Google Fonts fetches, full video encodes) and
did not complete in a reasonable time in this sandbox. Verified instead via
the targeted test file for all touched code (76/76 passing), whole-repo
typecheck/build/oxlint, `fallow audit` (complexity/duplication/dead-code
gate, clean), and manual end-to-end CLI runs (render/lint/validate) against
reproduction projects, including a nested sub-composition scenario. CI
should run the full producer suite before merge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(parsers,lint): port empty-composition pre-flight to extracted packages
Rebased onto main, which extracted @hyperframes/lint from core (lint depends
only on parsers, not core). Relocate checkSubCompositionUsability from core to
@hyperframes/parsers so both core (inliner) and lint can consume it without a
core<->lint cycle; core keeps a @deprecated re-export shim.
Correctness fixes from code review:
- checkSubCompositionUsability now returns "no-composition-root" when the
<template>/<body> content has no [data-composition-id] element (previously
a marker-free placeholder body passed both guards).
- lint's missing/empty sub-composition rule now only checks files reachable
via data-composition-src from the root (matching render pre-flight), instead
of a raw filesystem walk that false-positived on orphaned files.
- drop `as string` cast in inlineSubCompositions in favor of an explicit
null guard (per CLAUDE.md).
Review-comment items:
- move EmptyCompositionError JSDoc above the class (was above the adapter fn).
- correct stale circular-ref comment to match actual silent-skip behavior.
- rewrite self-contradicting lint message ("silently drop") to describe the
new loud render-pre-flight abort.
- add the __PLACEHOLDER__ (/^__[A-Z_]+__$/) skip to the render pre-flight so
it agrees with lint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Renders were failing outright with "[FrameCapture] Composition has zero
duration. Runtime ready: false, ..." whenever window.__renderReady didn't
flip true within playerReadyTimeout (45s) — most often under host
contention (e.g. several renders running concurrently), never from a
defect in the composition itself. Confirmed by re-running an affected
composition standalone: it succeeded immediately (initMs ~3.5-4.4s vs.
the 45s timeout it hit under concurrent load).
The probe stage already retries once with a fresh browser session for
exactly this class of "succeeds on retry" infra flakiness (frame
detachment, disconnects, navigation timeouts, launch failures), but
isTransientBrowserError didn't recognize this message, so it fell
through to an immediate, unretried failure.
Match "Composition has zero duration ... Runtime ready: false" as
transient. Left the "Runtime ready: true" case (pollHfReady's fast-fail:
no GSAP timeline and no data-duration) unmatched — that's a genuine
authoring bug, not a timing fluke, and should keep failing fast.
* fix(core): root-cause the id-less media wash in getAttr, drop the band-aid
The blank-wash/dropped-audio fix in #1790 added assignMissingMediaIds in the
producer to stamp ids onto id-less timed media. That was a band-aid: the real
cause is timingCompiler's getAttr, whose regex had no name boundary at all, so
getAttr(tag, "id") matched the trailing id="…" inside data-hf-id="…". compileTag
saw a phantom id and skipped its existing hf-video-N/hf-audio-N injection,
leaving the element with no real el.id — which the render pipeline keys off of.
Fix getAttr with the same (?<![\w-]) lookbehind used for the lint readAttr fix.
compileTag's auto-id injection now fires for data-hf-id-only media, in both the
main composition and sub-compositions (parseSubCompositions runs the same
compileTimingAttrs pass), so assignMissingMediaIds is removed entirely.
Extends the regression fixture with a standalone id-less <audio> (the dropped-
audio side, previously untested) and raises minAudioCorrelation to 0.9. Adds a
timingCompiler test for the data-hf-id/id boundary.
* test(producer): use seeded pink noise (not a pure sine) for fixture audio
A continuous sine anti-aligns under the audio cross-correlation (correlation
-1.0 from a sub-period offset). Broadband seeded noise correlates robustly.
* test: cover audio-side id injection via unit test; keep render fixture video-only
The audio render-baseline used synthetic sine/noise, which anti-aligns under
the harness audio cross-correlation (deterministic -1.0). Real audio fixtures
are unaffected. Cover the audio side of the boundary fix with a deterministic
timingCompiler unit test (id-less <audio> gets hf-audio-N) instead, and keep
the render fixture video-only.
* test(producer): regenerate baseline under the root fix (hf-video-N from compileTag)
* fix(producer,lint): id-less media renders blank wash instead of footage
A timed <video>/<audio> identified only by a Studio-stamped `data-hf-id`
(no real `id`) rendered as a flat white/grey wash with dropped audio, and
lint stayed silent so it surfaced only at render.
Root cause, two layers:
- lint `readAttr(tag, "id")` used a `\b` boundary, which treats the hyphen
in `data-hf-id="…"` as a word break — so reading "id" matched the trailing
`id="…"` inside `data-hf-id` and returned a phantom id. `media_missing_id`
therefore never fired for media carrying only a data-hf-id. Switched to a
`(?<![\w-])` lookbehind so a short name can't match the tail of a longer
hyphenated attribute (also fixes "width" matching `data-width`, etc.).
- the render pipeline identifies media by the real `el.id`: frame extraction
keys injected stills as `__render_frame_<id>__`, the runtime frame-swap
matches on `el.id`, and the audio mixer selects `audio[id][src]`. An empty
`el.id` meant injected frames/audio never matched. compileForRender now
assigns a stable positional id to every id-less timed media element before
any stage parses or serves the HTML.
Adds a producer regression fixture (video with data-hf-id, no id) and a lint
test covering the data-hf-id/id collision. Baseline mp4 generated separately.
* test(producer): baseline for video-hfid-no-id regression fixture
Golden compiled.html + output.mp4 (generated on linux/amd64 in the
Dockerfile.test image). Compare-mode passes: compilation, visual (0 failed
frames), and audio (correlation 1.000). A regression to the blank-wash
behaviour fails the visual check.
Flush the GSAP proxy queue synchronously during capture session initialization and parallelize independent media/font/tailwind readiness waits.
Closes#1715.
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra_miga@heygen.com>
* refactor: extract @hyperframes/studio-server package from core
Moves all studio-api routes, helpers, and Hono server wiring from
packages/core/src/studio-api/ into a new standalone packages/studio-server
package (@hyperframes/studio-server).
Core keeps thin re-export stubs at @hyperframes/core/studio-api and the
subpath helpers (screenshot-clip, draft-markers, etc.) for backward
compatibility. Consumer imports (cli studioServer, vite adapter/config,
producer htmlCompiler, studio manualEditsTypes) are updated to import from
@hyperframes/studio-server directly.
Also exports rewriteInlineStyleAssetUrls from @hyperframes/core root (was
in compiler/rewriteSubCompPaths.ts but not re-exported), required by
@hyperframes/studio-server/helpers/subComposition.
Removes postcss-selector-parser from @hyperframes/core dependencies (moved
to @hyperframes/studio-server which owns the routes that used it).
Depends on @hyperframes/parsers (PR #1755).
* fix(ci): add parsers+studio-server to Dockerfile and build before preview tests
* fix(ci): build @hyperframes/studio-server before Test and studio load smoke
Studio's vite.config.ts imports @hyperframes/studio-server, which resolves
via its "node" export condition to built dist. The Test and studio-load-smoke
jobs only built parsers + core, so esbuild's config load failed to resolve the
package entry. Build studio-server too.
* fix(studio): repoint sdkCutoverParity test import to studio-server
sourceMutation moved from core's studio-api to @hyperframes/studio-server;
the test still imported the deleted core path. This was masked while studio's
vite.config failed to load (couldn't resolve studio-server); now that the
config loads, the test runs and the stale import surfaced.
* refactor: extract @hyperframes/lint package from core
Moves all lint rules, hyperframeLinter, lintProject, and related types
from packages/core/src/lint/ into a new standalone packages/lint package.
Core keeps a thin re-export stub at @hyperframes/core/lint for backward
compatibility. Consumer imports (cli lint command, producer hyperframeLint)
are updated to import from @hyperframes/lint directly.
Depends on @hyperframes/parsers (PR #1755).
* fix: restore postcss-selector-parser in core (sourceMutation.ts still uses it)
* fix(ci): add parsers+lint to Dockerfile and build before preview tests
* chore: update bun.lock after restoring postcss-selector-parser dep
* test(cli): update lintProject test for string-dir signature from @hyperframes/lint
* refactor(core): single-source the lint engine in @hyperframes/lint
Delete core's byte-identical copy of the lint rule engine and re-point
staticGuard at @hyperframes/lint, so the render-time render-gate and the
studio preview share one rule engine instead of two copies that could
silently diverge. Back-compat preserved via the @hyperframes/core/lint stub.
Addresses review feedback on the dual-copy footgun.
* perf(producer): stream binary file responses, async-read HTML
Replaces the per-request readFileSync in fileServer's static file handler
with a createReadStream pipe (binary) and an async readFile (HTML). Static
asset serving no longer blocks the Node event loop.
Why
---
The pre-fix handler called readFileSync(filePath) on every binary asset.
On video-heavy compositions Chrome requests several 32MB video files
back-to-back; each readFileSync(32MB) blocked the main event loop long
enough to wedge concurrent /health responses and other timers.
Scope clarification — this addresses the event-loop block documented at
renderOrchestrator.ts:1277-1306 (the video-heavy regression class). It is
NOT the fix for today's infinite-duration incident; Miguel is shipping
that upstream as a plan()-time duration guard. The two are complementary:
- Miguel's guard kills the impossible-work input shape before chunk
planning so the producer doesn't try to enumerate 300B frames.
- This streaming fix removes the next-largest known main-thread block
(large binary I/O during video-heavy renders), so future wedge
classes don't kill otherwise-healthy probes either.
The companion worker_thread /health PR + the heygen-com/app probe-timeout
bump round out the defense-in-depth: even if some future code path
introduces another main-thread stall, the probe lives off-thread and the
budget is 30s anyway.
What changed
------------
fileServer.ts: switched both file branches off the sync I/O path.
- Binary (the hot path for video-heavy renders): readFileSync(filePath)
-> createReadStream + Readable.toWeb -> Response stream body.
Content-Length is set via statSync so Chrome's range-aware media
stack sees the size up front. The handler is now async because the
HTML branch awaits.
- HTML (small files; injected with pre/head/body scripts):
readFileSync(filePath, "utf-8") -> readFile(filePath, "utf-8").
The injection is still sync — pure string ops — only the disk read
moved off-thread. Index HTMLs are tiny (~200KB max for AI-generated
compositions) but a ms of stall per render-start adds up across a
fleet.
Test
----
fileServer.test.ts: added a streaming regression that pins three
properties on a 5MB synthetic binary asset (chunk-boundary spanning):
1. Correctness — served bytes match the file across multiple
createReadStream chunks (default 64KB highWaterMark).
2. Content-Length header is set from statSync.
3. Four parallel fetches all return identical content; the streaming
path doesn't serialize them.
All 31 fileServer tests pass locally (bun test).
TODO: link Miguel's upstream plan() duration guard PR once known.
— Jerrai
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(producer): implement Accept-Ranges + 206 Partial Content for fileServer
Delivers the range-request semantics the original PR body promised but
the diff did not implement. Without range support, Chrome's <video>
element issues full-file GETs on seek; with this commit it can issue
`Range: bytes=...` and get a sliced 206 back, so seek + partial-load
work without re-pulling the whole file.
- Add `parseRangeHeader` (exported for unit tests) covering the three
RFC 7233 single-range forms: bytes=START-END (closed), bytes=START-
(open-ended), bytes=-SUFFIX (last N bytes). Multi-range falls back to
`absent` (full 200) so we never reassemble multipart/byteranges.
- Binary path now returns 206 Partial Content with Content-Range +
sliced Content-Length on satisfiable ranges, 416 Range Not Satisfiable
with `Content-Range: bytes (asterisk)/<size>` on unsatisfiable ranges,
and 200 with `Accept-Ranges: bytes` on full-body GETs so clients know
ranges are supported.
- Add unit tests for parseRangeHeader (10 cases: 3 forms, clamping,
unsatisfiable edges, malformed inputs, multi-range fallback).
- Add integration test covering 200 + Accept-Ranges, all 3 range forms
with byte-correct slices, 416 on out-of-bounds, and multi-range -> 200
fallback.
Addresses Miga's review finding on #1735.
Co-Authored-By: Jerrai <noreply@anthropic.com>
— Jerrai
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(producer): serve /health from a worker_thread so probes survive main-thread stalls
Adds an off-main-thread /health endpoint that listens on its own port
(default 9848, env PRODUCER_HEALTH_PORT). The endpoint binds inside a
Node worker_thread with a minimal node:http server — separate event loop,
separate isolate — so probe responses don't depend on whatever the
producer's main thread is doing.
Why now
-------
Today's hyperframes-producer crashloop traced to an infinite GSAP
timeline -> distributed planner trying to enumerate ~300,000,000,000
frames -> sidecar /health stops landing within k8s's 5s window ->
otherwise-healthy pods killed.
Miguel is shipping the root-cause fix at plan() time (impossible /
non-finite / sentinel durations get rejected before chunk planning).
That removes today's wedge.
This change is defense-in-depth for the kill mechanism. Even with the
plan() guard, future wedge classes can stall the main event loop for
seconds at a time: large synchronous file I/O (see the companion
fileServer streaming PR), GC pauses on long-running renders, tight
loops in user-authored GSAP / Three.js / canvas code, future
activity / pool changes whose runtime cost we haven't yet characterized.
Probe responsiveness should reflect process liveness, not main-thread
event-loop responsiveness. If the entire Node process is dead the OS
tears down both threads' sockets simultaneously and k8s correctly kills
the pod. Anything short of that and the worker thread's listener keeps
answering.
Backwards-compatible: the main-thread /health on PRODUCER_PORT (9847)
keeps working exactly as before. The k8s sidecar probe config in
heygen-com/app can migrate to the worker port at its own pace. A
companion heygen-com/app PR in this batch raises the probe timeout
from 5s -> 30s as a last-resort backstop.
TODO: link Miguel's upstream plan() duration guard PR once known.
Test: healthWorker.test.ts (vitest) — 3 tests pass locally, including
the load-bearing one: stays responsive while the main thread is blocked
on a 500ms sync busy-spin.
— Jerrai
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(producer): tighten healthWorker startup race + shutdown semantics
Addresses Miga's review on #1733.
- server.ts: store the worker as a Promise<HealthWorkerHandle | null>
instead of mutating a `let` from inside `.then`. A SIGTERM landing
before the `.then` callback fired would previously see `healthWorker
=== null` and skip cleanup. shutdown() now `await`s the promise with
a bounded 1.5s timeout so a hung-startup worker can't keep SIGTERM
waiting (worker.terminate() from process exit still kills it).
- healthWorkerThread.ts: replace `process.exit()` inside the worker
with `parentPort.close()` + natural event-loop drain. Node-version
semantics for `process.exit()` from a worker have been historically
inconsistent; the documented clean path is to close the channel and
let the worker exit naturally. Also drops the redundant 2s force-exit
on shutdown — the parent already owns the authoritative deadline via
Promise.race + worker.terminate(), so the worker-side timer was
belt-and-suspenders noise.
Co-Authored-By: Jerrai <noreply@anthropic.com>
— Jerrai
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(producer): rebuildExtractedFramesFromPlanDir off-by-one in framePaths key indexing
In distributed chunk-lambda render mode, every <video>'s first-paint
frame (the moment a vid first becomes visible on the composition
timeline) renders as PRISTINE Y=16 black. For a 3-vid back-to-back
composition (v1: 0-4s, v2: 4-8s, v3: 8-12s at 30fps), frames 0, 121,
242 are all PRISTINE black; the render then either stays black for 1
frame, or shows body bg + persistent overlays only (Y~22 with sparse
highlights from text/logo). The symptom only reproduces in distributed
mode — local single-process renders are unaffected.
Root cause: rebuildExtractedFramesFromPlanDir builds the framePaths Map
with 1-based keys, but the consumer (getFrameAtTime at
engine/videoFrameExtractor.ts:958) computes a 0-based frame index via
Math.floor(localTime * fps + 1e-9). For each vid's first-paint frame
(localTime === 0 → frameIndex === 0), framePaths.get(0) returns
undefined; the vid is silently dropped from activePayloads,
videoFrameInjector doesn't fire, syncVideoFrameVisibility hides
everything, and BeginFrame screenshots an empty composition.
Every other site in the codebase builds/consumes framePaths with
0-based keys:
- engine/videoFrameExtractor.ts:317 framePaths.set(index, ...)
- engine/extractionCache.ts:204 framePaths.set(idx, ...)
- engine/videoFrameExtractor.test.ts:264/1066 framePaths.set(i, ...)
- engine/videoFrameExtractor.ts:958 (consumer) Math.floor 0-based
- producer/renderOrchestrator.test.ts:287/315/349 framePaths.get(0)
Only producer/distributed/renderChunk.ts:198 was 1-based, with a
stale comment claiming FrameLookupTable indexes frames 1-based —
which the surrounding evidence contradicts. This is why local tests
pass while distributed-lambda renders always had cold black at each
vid first paint.
Verified locally against a 3-vid composition and a single-vid 4-worker
case in a Lambda render fleet. Before fix: every vid first-paint frame
is PRISTINE Y=16 black. After fix: all frames are valid source content,
blackdetect reports zero black regions outside legitimate source video
content (intentional fade-ins / hard cuts in source mp4).
* test(producer): pin rebuildExtractedFramesFromPlanDir 0-based framePaths contract
Regression guard for the off-by-one fix in HF#1730. The pre-fix code
indexed framePaths 1-based while the consumer (getFrameAtTime in
engine/videoFrameExtractor.ts:958) reads 0-based, dropping every
<video>'s first-paint frame in distributed chunk-lambda renders.
Asserts framePaths.get(0) resolves to the first extracted frame, and
framePaths.get(N-1) resolves to the last — pre-fix the keys were
shifted to [1..N], so get(0) returned undefined and get(N) resolved.
Verified to fail against the previous i+1 indexing.
Also exports rebuildExtractedFramesFromPlanDir (was module-local) so the
test can call it directly. Pure logic worth testing in isolation — the
bug only reproduces under distributed mode and the existing
renderChunk.test.ts already pays a multi-second Chrome smoke probe in
its module-level beforeAll, so the regression check lives in its own
file (rebuildExtractedFrames.test.ts) and runs Chrome-free in ~10ms.
The function's doc comment said "1-based framePaths" — updated to
"0-based" with a pointer to the consumer site and the bug context.
Per Miguel's REQUEST_CHANGES on HF#1730.
— Jerrai (https://claude.com/claude-code)
---------
Co-authored-by: James <james.russo@heygen.com>
Renders showed the page background (a one-frame black flash) right before a cut
when a video clip's source media was a hair shorter than its data-duration slot
— the common case, since `ffmpeg -t 1.45` emits 43 frames = 1.433s at 30fps.
The frame lookup only held the last frame at the exact clip end, so the
sub-frame remainder rendered blank.
- Hold the last extracted frame for the rest of the slot once the source is
exhausted, within a tolerance floored at the compiler's 0.05s clamp epsilon so
the seam is covered at any fps (2 frames alone is < 0.05s above 40fps). Clips
deliberately much shorter than their slot still blank for the tail (unchanged).
- Warn when the compiler clamps a video's data-duration down to its media length
(slot longer than source by more than the clamp epsilon): a render-time
`[compile]` warning in the producer, plus a matching `validate` warning that
reads each <video>'s live duration in headless Chrome (static HTML lint can't
see media durations). A shared `analyzeClipMediaFit` keeps both on one
threshold.
Adds engine unit tests for the hold behavior and the analyzer.
* fix(producer): stop retrying capture attempts that made zero progress
A structurally broken composition (never-ready page, zero duration, or
unparseable HTML) captures no frames, so the adaptive retry loop kept
re-running it at halved parallelism — 16->8->4->2->1 workers — each attempt
burning a full readiness/protocol timeout per worker. That multiplied
wall-clock to ~46min on broken renders and was the driver of the render
P95 blowup (~370k -> 2.79M ms) seen Jun 20-22.
Add captureAttemptMadeProgress(): when an attempt leaves at least as many
frames missing as it set out to capture, it made no forward progress, so the
composition is broken rather than the workers being flaky. Bail immediately
instead of retrying. A partially-captured attempt still retries, so genuine
flaky-worker gaps are unaffected.
* fix(producer): log the zero-progress bail + cover it with an integration test
Address review feedback on the no-progress capture guard:
- Warn before bailing so an oncall can tell a structurally-broken render that
bailed fast apart from one that exhausted worker-halving retries (both
previously threw the same "frame(s) are missing" message).
- Add an integration test that drives executeDiskCaptureWithAdaptiveRetry
through the bail (capture functions mocked to write nothing) and asserts a
single attempt runs — the gate would otherwise walk 4->2->1 workers. Guards
the placement of the gate, not just the predicate.
- Reword the helper docstring (drop stray prefix and internal incident detail).
* fix(producer): retry probe stage on transient browser errors (#1687)
The distributed render plan stage crashes when headless Chrome encounters
a transient frame detachment ("Navigating frame was detached") during
browser probe, with no retry logic. The plan tarball is never uploaded,
and all downstream chunk workers fail with S3 404.
Add a retry-with-fresh-session mechanism to the probe stage:
- `isTransientBrowserError()` classifier in the engine identifies 9
known transient Puppeteer/Chrome errors (frame detached, target closed,
session closed, protocol error, page crashed, execution context
destroyed, etc.).
- `runProbeStage()` wraps browser session creation + initialization in a
retry loop (max 2 attempts). On transient error: logs structured
diagnostics (attempt, isTransient, error message, elapsed time), closes
the crashed session cleanly, creates a fresh browser, and retries. Non-
transient errors throw immediately without consuming retry budget.
- 17 unit tests for the error classifier, 3 integration tests for retry
behavior (successful retry, immediate throw on non-transient, exhaust
retry budget on persistent transient).
Closes#1687
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review feedback — widen retry scope, deduplicate patterns
- Move createCaptureSession inside the retry try/catch so browser launch
failures (Failed to launch the browser process, ECONNREFUSED) are also
retried — not just initializeSession errors.
- Deduplicate transient error patterns: remove "Protocol error.*Target
closed" (subsumed by "Target closed") and "Navigation failed because
browser has disconnected" (subsumed by "browser has disconnected").
- Add browser launch failure patterns: "Failed to launch the browser
process" and "ECONNREFUSED".
- Add test for createCaptureSession transient throw (browser launch retry).
- Update test mock comment to document sync requirement with engine
pattern list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>