- Replace magic 0.001/0.999 with TRANSFORM_IDENTITY_EPSILON and
OPAQUE_ALPHA_THRESHOLD; replace BPP=6 with RGB48_BYTES_PER_PIXEL
- Add AffineMatrix tuple type + isAffineMatrix guard, eliminating
all 4 non-null assertions on matrix indices
- Extract resolveBlitOpacity() to replace 5 identical ternaries
- Narrow fallow-ignore-file to line-level complexity suppressions
- Extract seekInjectAndQueryStacking() and seekAndInject() helpers to
deduplicate the seek+inject+query pattern across sequential loop,
hybrid loop, and per-scene transition capture (3 call sites → 1 helper)
- Fix sceneBuf as Buffer casts by properly typing the scene-capture
arrays as [Buffer, Set<string>][] instead of using as const + cast
- Replace as NonNullable<> cast on outputFormat with as const fallback
- Add explanatory comments on inherent linkedom DOM casts
Move fullStacking.map() from inside the per-layer iteration to before
the loop, computing the element ID list once per frame instead of once
per DOM layer per frame.
Move ~700 LOC of HDR compositing primitives (countNonZeroAlpha,
countNonZeroRgb48, cropRgb48le, HdrVideoFrameSource,
closeHdrVideoFrameSource, blitHdrVideoLayer, HdrImageBuffer,
blitHdrImageLayer, CompositeTransfer, shouldUseLayeredComposite,
resolveCompositeTransfer, HdrCompositeContext, compositeHdrFrame,
HdrTransitionMeta, TransitionRange) into a dedicated
hdrCompositor.ts module.
Remove backward-compat re-exports from renderOrchestrator (hdrPerf,
captureCost, shared) and rewire all import sites to the
authoritative source modules.
The pool (455 LOC) and worker (127 LOC) were built speculatively for
pipelining Chrome screenshots with PNG decode/blit but were never
wired into any capture path. Zero non-test source files imported them.
Also removed the esbuild entry point from producer/build.mjs, the
tsup entry point + alpha-blit alias from cli/tsup.config.ts, and
the PNG worker bootstrap from cli/src/cli.ts.
screenshotService.ts, videoFrameExtractor.ts, videoFrameInjector.ts,
and streamingEncoder.ts existed solely to re-export symbols from
@hyperframes/engine. No internal consumer imported from them except
index.ts → videoFrameInjector, which now imports directly from engine.
* fix(render): make WebGL video textures deterministic in headless render
WebGL compositions that sample a `<video>` as a texture (e.g. a faceted
crystal with clips mapped onto its facets) rendered with flickering,
non-deterministic facets: a video would intermittently show a stale frame or
go black, and the same frame differed between two renders.
Two gaps caused this:
1. No WebGL analog of the WebGPU `patchVideoTextureCompat`. Chrome's headless
compositor can't feed decoded `<video>` frames to the GPU, so the engine
injects a decoded `<img class="__render_frame__">` sibling per video each
frame. The WebGPU `copyExternalImageToTexture` path substitutes it, but
`texImage2D` / `texSubImage2D` did not — so WebGL uploaded a stale/black
frame. Add `patchWebGLVideoTextureCompat()` mirroring the WebGPU patch
(shared `resolveRenderFrameImage` helper).
2. Capture ordering. Per frame the runtime seeks (GPU adapters render on
`hf-seek`) BEFORE the engine injects the decoded frames, so the GPU render
read a frame that didn't exist yet. After injecting, the engine now calls
`window.__hfReseekGpu(t)` — a force-dispatch (`forceDispatchSeekEvent`) that
bypasses the same-time `hf-seek` dedup — so GPU compositions re-upload their
textures from the freshly-injected, decoded frames, deterministically.
Tests: unit tests for the texImage2D/texSubImage2D substitution and the
force-dispatch, plus a videoFrameInjector regression test asserting the
post-injection GPU reseek fires only when frames were injected. Verified
end-to-end: a WebGL prism with 8 live <video> facets renders byte-identical
across independent runs with no facet flicker.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(render): add producer render-compat regression for WebGL video textures
A WebGL2 canvas samples a <video> as a texture every hf-seek (the natural
author pattern, distilled from the HeyGen prism). The render-compat harness
renders it and compares against the golden: with the video-texture fix the
render reproduces the decoded frames; revert the fix and the canvas renders
black, collapsing the comparison.
Golden verified to contain real, time-varying video content (not black), so a
regression is caught rather than passing vacuously.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
writeFrame returned the stdin.write boolean synchronously; when FFmpeg
encoded slower than workers captured, Node's writable buffer grew without
bound (multi-worker worst case ~80GB over a 1h render) until the kernel
OOM-killed the process. writeFrame is now async: a buffered write awaits
the drain event before resolving, so back-pressure propagates through the
frame reorder buffer to the capture loops and in-flight frames stay
bounded. Inactivity-timer semantics are preserved: no reset before drain,
so a hung FFmpeg still trips SIGTERM.
The drain wait races one-shot drain/close listeners (aborted in a
finally) rather than chaining onto the shared exit promise — V8 retains
reaction-list entries on unsettled promises, so per-frame .then chains
would accumulate ~108K closures over a 1h back-pressured render. An
exit-status re-check after listener attachment closes the
close-before-attach hang window. All five writeFrame call sites
(streaming stage and HDR loops) check the result via a shared
ensureFrameWritten guard and stop the render with a frame-indexed error
when the encoder is gone instead of discarding the boolean.
The MULTI_WORKER_MAX_DURATION_SECONDS cap can be relaxed in a follow-up
now that buffering is bounded.
Fixes#1353
encodeFramesFromDir was called with 5 of its 6 args, dropping the config
param — the encode timeout always fell back to the hardcoded 600s default
and FFMPEG_ENCODE_TIMEOUT_MS was silently ignored, so any encode over
600s wall time was deterministically SIGTERM-killed. Resolve the engine
config once in the encode stage and pass it to the non-chunked, chunked,
and GIF paths. The chunked per-chunk encodes previously had no timeout at
all; they now honor the same config value.
Review follow-ups: the chunked path's final concat spawn gains the same
config-driven timeout (it previously had none); every encode-timeout
kill now appends 'FFmpeg killed after exceeding ffmpegEncodeTimeout
(N ms)' to the failure instead of surfacing a bare exit-255; and the
orchestrator threads its already-resolved config into the encode stage
via an optional EncodeStageInput.engineConfig field (direct callers and
distributed chunks keep the producerConfig ?? resolveConfig() fallback).
The encode-timeout tests run against a mocked child_process spawn with
fake timers, removing the real-ffmpeg dependency that made the previous
default-timeout test environment-fragile in CI.
Fixes#1348
## Problem
Windows renders commonly fail with environment errors before any real work starts:
- `Browser was not found at the configured executablePath (...chrome-headless-shell.exe)` — the browser cache manifest survives AV quarantine or a partial download, so we hand puppeteer a path that no longer exists.
- `[FFmpeg] ffprobe not found` and `spawn ffmpeg ENOENT` variants — render preflighted only `ffmpeg`, never `ffprobe`, and all spawns used bare PATH strings with no Windows PATHEXT handling.
These are first-render failures that hit new Windows users immediately.
## Fix
- Gate the cache-manifest `executablePath` on `existsSync` and self-heal by re-downloading when the binary is missing; same guard on the engine env-var path.
- New shared environment preflight (`packages/cli/src/browser/preflight.ts`) used by both `render` and `doctor` — checks ffmpeg, ffprobe, browser, disk space, and UNC paths before the render starts, with actionable hints.
- Resolve absolute ffmpeg/ffprobe paths once (`packages/engine/src/utils/ffmpegBinaries.ts`) and pass them to every engine spawn instead of relying on PATH.
- Map opaque Windows ffmpeg exit codes to actionable messages.
## Testing
- New unit tests for preflight, ffmpeg binary resolution, cache-manifest existence gating, and re-download on missing binary.
- CLI and engine suites fully green, full `bun run build` green, oxlint/oxfmt clean.
- Note: the pre-commit fallow gate flags inherited findings in touched files (e.g. `audioExtractor.ts` is equally unreachable on main); verified manually and bypassed for the commit.
## Problem
The most common render failure in recent reports is:
```
Cannot destructure property 'firstElementChild' of 'documentElement' as it is null.
```
It appears when a `data-composition-src` file resolves to empty or unparsable HTML, and started showing up after the render pipeline change in 0.6.73.
## Root cause
When a sub-composition file is empty or unparsable, linkedom's `parseHTML` returns a document with a null `documentElement`, and the shared inliner (`packages/core/src/compiler/inlineSubCompositions.ts`) dereferences `.body`/`.head` on it, crashing inside linkedom internals with the cryptic destructure error instead of telling the user what's wrong.
## Fix
Guard the resolved sub-composition HTML and the extracted content HTML in the shared inliner: empty or unparsable input now fails with an actionable error naming the offending file.
## Testing
- New tests in core and producer reproducing the empty sub-composition case (previously crashed with the destructure error, now throws the actionable message).
- `bun run build` green, all tests pass in the changed test files.
## Summary
Fixes#1317 — systematic duplicate+skip video frames when clip `data-start` is aligned to the output frame grid.
### Root cause
`Math.floor(localTime * fps)` in `getFrameAtTime` produces off-by-one errors when the product lands exactly on an integer boundary due to IEEE 754 float noise. For example, `0.28 * 25 === 6.999999999999999` instead of `7`, causing `Math.floor` to return 6 (duplicate of previous frame) instead of 7.
### Fix
1. Add `1e-9` epsilon before flooring: `Math.floor(localTime * fps + 1e-9)` — nudges boundary values like `6.999999` to `7.000000` without affecting mid-frame values.
2. Include `mediaStart` in the frame index computation so trimmed clips (`data-media-start`) map to the correct extracted frames.
Both call sites fixed: `getFrameAtTime()` (public API) and the `FrameLookupTable.getFramesAtTime()` bulk lookup.
### Reporter's measurements (before fix)
| Case | Duplicates (of 351 frames) |
|---|---|
| Source file | 1 |
| data-start="0" | 14 |
| data-start="230.44" (production) | 127 |
| data-start="0.02" (half-frame offset workaround) | 1 |
## Test plan
- [x] 4 new regression tests for IEEE 754 boundary precision
- [x] No duplicate frames when data-start is grid-aligned (25fps)
- [x] Monotonically increasing frame indices across 100 frames
- [x] Correct frame at the `0.28 * 25` boundary (frame 7, not 6)
- [x] `mediaStart` correctly offsets frame index
- [x] Typecheck clean
* fix(producer): don't mix audio from muted videos into the render
The auto-detect audio block checked ext.metadata.hasAudio (file has
audio track) but not video.hasAudio (element declares itself audible).
A <video muted> whose source file contains audio leaked that audio
into the final render at full volume.
Add video.hasAudio guard so only audible elements contribute audio.
* test(producer): add unit tests for muted video audio guard
* fix: format
* fix(producer): revert Proxy-based wrapTimeline to plain-object approach
The `new Proxy` wrapper for GSAP timelines introduced in #1279 causes
Chrome headless to hang indefinitely during page.goto — DOMContentLoaded
never fires. The plain-object approach (explicit method allowlist) loads
in <800ms on the same composition.
The Proxy's generic get/set traps interact badly with Chrome's internal
object inspection (Symbol checks, thenable probing, DevTools serialization)
during HTML parsing, creating a permanent navigation hang. The
maybePublishRenderReady listener fix from #1279 is preserved — only the
wrapTimeline implementation is reverted.
Compositions using GSAP methods outside the allowlist (eventCallback,
labels, repeat, etc.) will see those calls silently dropped rather than
forwarded. This is the same behavior as v0.6.81 and earlier. A safer
forwarding approach can be explored separately without blocking renders.
* fix(producer): address review — stale meta.json descriptions + silently-dropped methods doc
- three-boundary: description referenced Proxy fix but the test uses
onUpdate in to() vars (allowlist path), not eventCallback
- three-boundary-deferred: same — pins Bug 2's deferred-race, not Bug 1
- Add inline doc comment listing silently-dropped GSAP methods and the
onUpdate workaround
* ci: add page.goto timing canary to CLI smoke test
Parse page.goto completion times from the render log and fail if the
slowest navigation exceeds 5s. Catches wrapTimeline regressions that
block DOMContentLoaded before the 60s timeout fires.
Refs: #1285
* fix(producer): forward all GSAP methods via dynamic enumeration at wrap time
Instead of silently dropping methods outside a static allowlist, enumerate
the real timeline's prototype chain at wrap time and generate plain-object
forwarding stubs for every method not already covered.
This achieves the same coverage as the `new Proxy` approach from #1279
without the Chrome headless navigation hang — no Proxy trap surfaces are
exposed to Chrome internals. Methods prefixed with `_` (GSAP private) are
skipped. All forwarded methods flush pending batch operations before
delegating, matching the existing allowlist behavior.
Closes#1285
* fix(producer): make proxy non-thenable + harden CI canary
- Skip `then` in forwardRemainingMethods — GSAP timelines are thenable
(tl.then resolves on completion), and forwarding it makes the proxy
thenable too: Promise.resolve(proxy) or await proxy hangs forever for
paused timelines
- Add unit test: Promise.resolve(proxy) resolves immediately, real
then() is never called
- CI canary: exit 1 (not 0) when no page.goto timing is found in logs,
so a log-format change loudly breaks CI instead of silently disabling
the canary
Compositions that defer gsap.timeline() registration past DOMContentLoaded
(via setTimeout, template instantiation, or dynamic script loading) hit a
race where __renderReady stays false forever:
1. At DOMContentLoaded, __hfTimelinesBuilding is false — init.ts skips
the hf-timelines-built listener and sets __renderReady = true
2. The deferred script runs, calls gsap.timeline().to() which sets
__hfTimelinesBuilding = true via the batching proxy
3. The deferred maybePublishRenderReady() sees building=true, sets
__renderReady = false, but never registers a listener to retry
4. __renderReady stays false, __hf.duration returns 0, pollHfReady
times out with "Composition has zero duration"
Fix: when maybePublishRenderReady encounters __hfTimelinesBuilding=true,
register a one-shot hf-timelines-built listener to retry — matching the
pattern already used at init time for the synchronous batching case.
Closes#1260
Compositions are now self-contained: the compiler captures font files
and embeds them as woff2 data URIs, eliminating silent render-time
fallback when the render environment lacks the author's fonts.
Resolution order (each tier falls through to the next):
1. Existing @font-face → use as-is
2. Bundled alias (38 cross-platform mappings) → embed data URI
3. Google Fonts → fetch, cache, embed
4. Local system font → locate on OS, compress to woff2, embed
5. Local @font-face paths → read file, compress, inline as data URI
6. External CDN stylesheets → fetch CSS, extract @font-face, inline
7. Alias map fallback → closest bundled equivalent
8. Actionable error with guidance
Key changes:
- System font locator (macOS/Windows/Linux) with path-bounding and
symlink defense (realpathSync + O_NOFOLLOW)
- woff2 compression via wawoff2 (WASM, cross-platform)
- Multi-weight/style variant capture with length-sorted token matching
- External stylesheet inlining with SSRF defense (assertPublicHttpsUrl,
HTTPS-only, private-host blocking, 2MB cap, 4-concurrent limit)
- Studio auto-import via GET /fonts/file API + renderAliasFor() derived
from shared FONT_ALIAS_MAP (no more hand-curated drift)
- failClosedFontFetch throws on unresolved fonts in distributed renders
- Single source of truth: @hyperframes/core/fonts/aliases
- system_font_will_alias lint rule (escalates to warning for distributed)
- Default to Inter + JetBrains Mono in templates and CSS reset
* feat(gcp-cloud-run): add Google Cloud Run + Workflows distributed render adapter
Adds @hyperframes/gcp-cloud-run, the GCP counterpart to @hyperframes/aws-lambda
(issue #932). The OSS distributed primitives (plan, renderChunk x N, assemble)
are unchanged; this package is the storage/compute/orchestration glue.
Package: Cloud Run handler (one image, three actions), runs under bun; GCS
transport; in-image chrome-headless-shell resolver; client SDK
(renderToCloudRun, getRenderProgress, deploySite, computeRenderCost); Dockerfile;
Cloud Workflows definition; Terraform module; CLI cloudrun
deploy|sites|render|render-batch|progress|destroy with --output-resolution and
--strict-variables; 62 unit tests + docs + live smoke script.
Shared extraction (removes ~640 lines of adapter duplication): move the
cloud-agnostic config validator + content-hash into producer/distributed; both
adapters import them. Validated end-to-end on GCP at 37.4 dB PSNR vs baseline.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): resolve @hyperframes/gcp-cloud-run in the CLI build + root build
The CLI bundle (esbuild) couldn't resolve `@hyperframes/gcp-cloud-run/sdk`,
failing Build/Typecheck/CLI-smoke (and the perf/windows/regression jobs that
build first). Mirror the aws-lambda handling: mark the gcp adapter + its /sdk
subpath external in tsup.config.ts with a source alias, and add gcp-cloud-run
to the root `build` filter so its dist exists for publish + runtime.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(ci): copy gcp-cloud-run manifest in Dockerfile.test for frozen install
The regression test image runs `bun install --frozen-lockfile` after copying
each workspace package.json individually. The CLI now depends on
@hyperframes/gcp-cloud-run (workspace:*), so the frozen install fails to
resolve it unless its manifest is present. Add the COPY line.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(cli): add machine-sizing flags to `cloudrun deploy`
Closes the parity gap with `lambda deploy` (which exposes --memory etc.).
`cloudrun deploy` now threads --cpu, --memory, --max-instances, and --timeout
into the Terraform apply; omitted flags keep the module defaults
(4 vCPU / 16Gi / 100 instances / 3600s). For finer control, apply the module
directly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(gcp-cloud-run): address PR review (security, waste, limits, alerts)
- server.ts: bucket-allowlist guard no longer fails open silently. Unset env
logs a one-time WARNING; "*" is an explicit opt-out; otherwise it enforces.
- server.ts: stop double-shipping audio.aac. It already rides in the plan
tarball every consumer downloads, so drop the redundant standalone upload
(plan) + re-download/overwrite (assemble); assemble reads it from the untar,
falling back to a supplied AudioGcsUri for compat.
- server.ts: chunk extension via path.extname() instead of slice(lastIndexOf).
- workflow.yaml: clamp parallel concurrency_limit to math.min(chunkCount, 20)
— Cloud Workflows hard-caps concurrent iterations at 20.
- Dockerfile: pin bun (bun-v1.3.9) so an interop change can't silently break
the image rebuild.
- terraform: add min_instances var (default 0); add a workflow-failure alert
(finished_execution_count status=FAILED) alongside the request-count one.
- costAccounting: document that displayCost excludes GCS storage/egress.
Verified against the actual APIs: @google-cloud/workflows@4.4.0
ICreateExecutionRequest has no executionId (so the idempotency-token suggestion
isn't available in this client); Workflows concurrency cap is 20; failure
metric is workflows.googleapis.com/finished_execution_count (status label).
174 adapter tests pass, fallow/oxlint/oxfmt/terraform clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(gcp-cloud-run): address round-2 review — error code + CFR forwarding
- workflow.yaml: rename the zero-chunk failure code PLAN_TOO_LARGE →
PLAN_PRODUCED_ZERO_CHUNKS. The old code implied a size-ceiling breach (the
opposite cause), misleading anyone triaging the alert.
- workflow.yaml: forward Config.cfr to the assemble step
(`Cfr: ${("cfr" in config) and config.cfr}`). It was read by the handler
but never sent, so exact-CFR was silently off for every Cloud Run render.
Uses the same `in`-operator guard already proven in the retryable predicate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(release): include gcp-cloud-run in set-version PACKAGES list
set-version.ts (driven by release:prepare) bumps an explicit package list to
the shared version on each release. gcp-cloud-run was wired into the build +
publish.yml but missing here, so a release would leave it at a stale version
and publish.yml would push the wrong version. Add it so the new package
version-bumps + publishes in lockstep with the others.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: batch GSAP timeline construction to prevent main-thread hang (#1231)
Compositions with thousands of tl.to() calls (e.g. 8,562 in the
reported case) block Chrome's main thread synchronously during HTML
parsing, preventing DOMContentLoaded from firing before Puppeteer's
navigation timeout. This caused render jobs to hang indefinitely at
'Initializing calibration session...' with no error message.
Root cause: GSAP's timeline API is synchronous — each tl.to() call
registers a tween immediately on the main thread. A script with 8k+
calls holds the thread for seconds, starving the browser event loop and
delaying DCL past the navigation timeout window.
Fix: install a property trap on window.gsap in HF_EARLY_STUB (injected
at the top of <head>, before GSAP or user scripts load). When GSAP
assigns itself to window.gsap, the setter intercepts the real gsap
object and wraps gsap.timeline() to return a proxy that queues tween
descriptors (to/from/fromTo/set) instead of calling them synchronously.
A requestAnimationFrame-based flush loop drains 100 tweens per frame,
yielding the main thread between batches so DCL can fire.
When the queue is drained, the stub sets window.__hfTimelinesBuilding =
false and dispatches a 'hf-timelines-built' CustomEvent. init.ts checks
this flag at DOMContentLoaded time; if building is still in progress it
defers bindRootTimelineIfAvailable() until the event fires, then sets
window.__renderReady = true as normal. pollHfReady continues to gate
on both __renderReady and window.__hf.duration > 0, so the render
pipeline does not start until the full timeline is bound.
- Batch size: 100 tweens/rAF tick (empirical; ~4ms/batch at 8k scale)
- Yield mechanism: requestAnimationFrame (cooperative, no setTimeout(0))
- Determinism: 'hf-timelines-built' event guarantees sequencing
- Proxy forwards: pause/seek/totalTime/time/duration/add/paused/
timeScale/play delegate to the real timeline immediately
- No GSAP package changes; no navigation timeout increase
Fixes#1231
* style: apply oxfmt formatting to producer stub files
* fix(producer): unwrap proxy children in add(), gate setter return on args.length
Addresses two latent correctness concerns from code review:
1. proxy.add() now unwraps __hfReal from any proxy child before passing it
to the real timeline. GSAP's internal tween graph (_first/_next/_prev
linkage) requires real timeline instances — proxy objects lack internal
fields like _dp that GSAP's iteration paths expect.
2. totalTime/time/paused/timeScale now return proxy when called in setter form
(args.length > 0). Previously these returned the real timeline, causing
callers who chain .to(...) after a setter call to bypass batching.
Also: build-hf-early-stub.ts now runs oxfmt on the generated output file
so the format check passes in CI on every build.
* fix(producer): gate __hf.duration=0 while GSAP timelines are batching
The HF_BRIDGE_SCRIPT duration getter now returns 0 whenever
window.__hfTimelinesBuilding is true (set by HF_EARLY_STUB while the rAF
batch loop is draining queued tl.to() calls).
pollHfReady in the engine polls until window.__hf.duration > 0, so
returning 0 keeps the engine waiting until the hf-timelines-built event
fires and all tweens are committed to the real GSAP timelines.
Without this gate, normal compositions (style-6, style-13, vignelli)
were being captured mid-batch — the real timelines were empty so GSAP
could not seek them, producing frozen/blank frames in the output video.
* fix(producer): flush GSAP batching under virtual time
* fix(producer): gate render bridge on runtime readiness
* fix(producer): preserve timeline child binding under batching
Page.goto with waitUntil:'load' blocks until all video sources finish
preloading metadata. With 6+ video sources (8–21s each), this reliably
exceeds the 60s navigation timeout, causing renders to hang at
'Initializing calibration session...' on M4 and other Apple Silicon macs.
Switch to waitUntil:'domcontentloaded' so navigation resolves as soon as
the DOM is ready, without gating on video media loading.
Fixes#1231