Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (lastFrameCache row) and §17.2
(gating table).
Adds `discardWarmupCapture(session, frameIndex=0, time=0, innerCapture?)`
in packages/engine/src/services/frameCapture.ts. Performs one capture
through the standard `captureFrameCore` path, throws the buffer away, and
restores the session's perf and BeginFrame damage counters.
Distributed chunk workers need this because Chrome's BeginFrame screenshot
pipeline maintains a per-process `lastFrameCache`: when a captured frame's
`hasDamage` reports `false`, the screenshot path returns the previously
captured buffer. For chunk N (N > 0) the worker has no prior frame in its
cache, so the very first capture's `hasDamage` reporting diverges from
what an in-process render at the same absolute frame index would see (the
in-process renderer always has frame N-1 cached). Running a discarded
warmup capture before the first real capture primes the cache so chunk
output is byte-identical to in-process output.
The wrapper:
- Takes an injectable `innerCapture` so tests can stub the Chrome path
(default is the real `captureFrameCore`).
- Restores `session.capturePerf`, `beginFrameHasDamageCount`, and
`beginFrameNoDamageCount` after the inner call — even on error — so
warmup captures don't pollute `getCapturePerfSummary()` averages.
- Writes no file to disk.
In-process behavior is unchanged: no caller invokes the new helper yet.
Phase 3's `renderChunk()` will run it as the first step after
`initializeSession` resolves.
Re-exported from packages/engine/src/index.ts.
7 unit tests at packages/engine/src/services/
frameCapture-discardWarmup.test.ts cover the post-conditional contract:
single inner-capture invocation, perf/damage restoration on success,
restoration on error, no-fs-write.
This is part of a stack of 10 PRs; this is PR 6 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §4.3 (LockedRenderConfig.runtimeEnv) and
§5.2 (RENDER_SEEK_MODE row).
`fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*`
env vars at module-load time (RENDER_SEEK_MODE, RENDER_SEEK_STEP,
RENDER_SEEK_OFFSET_FRACTION, …) and bakes them into the served HTML's
RENDER_MODE_SCRIPT. Distributed chunk workers are separate processes that
may inherit a different environment, so the plan needs to freeze a
snapshot.
Adds `snapshotRuntimeEnv(env = process.env)` in
packages/producer/src/services/render/stages/freezePlan.ts. Captures keys
matching `PRODUCER_RUNTIME_` or `PRODUCER_RENDER_` prefixes into a fresh
plain object, ignoring everything else. Phase 3's `renderChunk` will
materialize the snapshot back into `process.env` before launching its
file server.
Also exports `RUNTIME_ENV_SNAPSHOT_PREFIXES` so the chunk-worker side can
apply the same prefix filter (asymmetric handling would leak stale
controller env into worker behavior).
The freezePlan function body remains a skeleton — Phase 3 owns the full
implementation. The snapshot helper is exported on its own so this gate's
unit test can pin the behavior without depending on the not-yet-written
freezePlan body.
In-process behavior is unchanged: no in-process caller invokes
freezePlan or snapshotRuntimeEnv yet.
9 unit tests at packages/producer/src/services/render/stages/
freezePlan.test.ts cover: prefix matches (both families), non-matching
keys ignored, undefined values skipped, fresh-object contract, and
default-to-process.env behavior.
This is part of a stack of 10 PRs; this is PR 5 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (Math.random row) and §17.2
(gating table).
The existing `VIRTUAL_TIME_SHIM` freezes Date.now / performance.now / rAF
on a render seek but leaves `Math.random` and `crypto.getRandomValues` as
native non-deterministic. Compositions that paint stochastic visuals
through these APIs produce different pixels on distributed retries.
This change adds `buildVirtualTimeShim({ seedRandomFromFrame: boolean })`.
Default `false` returns a string byte-identical to today's
`VIRTUAL_TIME_SHIM` (pinned by a new unit test). When `true`, the script
additionally:
- Installs a Mulberry32 PRNG with a single uint32 state
- Reseeds the state from the current virtual time on every
`seekToTime(ms)` call (Knuth multiplicative hash + golden-ratio offset)
- Replaces `Math.random` with the PRNG output
- Replaces `crypto.getRandomValues` to fill the buffer from the PRNG
`VIRTUAL_TIME_SHIM` (the const consumed by `renderOrchestrator` +
`probeStage`) is now `buildVirtualTimeShim({ seedRandomFromFrame: false })`
— in-process behavior unchanged, producer regression baselines unaffected.
Phase 3 distributed primitives will pass `true` when building the chunk
worker's file-server scripts.
10 new unit tests at packages/producer/src/services/
fileServer-seededRandom.test.ts use node:vm to evaluate the shim in
isolated contexts and pin both branches:
- default emits no RNG override and leaves Math.random native
- locked emits the seeded block, produces identical sequences across
fresh VMs at the same time, and yields different sequences for
different times
This is part of a stack of 10 PRs; this is PR 4 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (warmupTicks row) and §17.2 (gating
table).
The BeginFrame warmup loop in `initializeSession` is driven by wall-clock
during page load — different hosts accumulate different tick counts before
page-readiness completes. That shifts `session.beginFrameTimeTicks` and
yields non-byte-identical captures on distributed workers.
This change adds `lockWarmupTicks: boolean` (default false) to
`CaptureOptions`. When false, behavior is unchanged. When true, the loop
runs exactly `LOCKED_WARMUP_TICKS = 60` iterations regardless of page-load
wall clock, and `session.beginFrameTimeTicks` is computed from the
constant — pinning the baseline across hosts.
Refactoring:
- Extract `driveWarmupTicks(options, state)` as a pure helper. Tests
drive it with a stub `tick` callback and an injected `sleep`, so the
iteration-count contract is unit-testable without real Chrome.
- `initializeSession`'s warmup body is now a thin adapter that calls
`driveWarmupTicks` with a CDP-backed tick.
Producer regression baselines remain byte-identical: the in-process
renderer never passes `lockWarmupTicks: true`. Phase 3 distributed
primitives will flip it true when launching chunk workers.
11 new unit tests at packages/engine/src/services/
frameCapture-warmupTicks.test.ts pin both branches (unlocked drifts with
simulated load time; locked produces identical counts).
This is part of a stack of 10 PRs; this is PR 3 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §5.2 (browserGpuMode row) and §9.3
(BROWSER_GPU_NOT_SOFTWARE typed failure).
Adds packages/engine/src/utils/assertSwiftShader.ts:
- assertSwiftShader(page, readInfo?) — navigates to chrome://gpu, reads
the GL_VENDOR / GL_RENDERER rows from browserBridge.gpuInfo_, throws
SwiftShaderAssertionError ({ code: "BROWSER_GPU_NOT_SOFTWARE" }) if
the active backend isn't SwiftShader.
- readWebGlVendorInfo(page) — extracted helper so tests can stub the
info read without spinning up real Chrome.
- SwiftShaderAssertionError + BROWSER_GPU_NOT_SOFTWARE constant exposed
so the Phase 3 distributed adapter can match typed non-retryable
failures.
Re-exported from packages/engine/src/index.ts. No caller invokes it yet;
Phase 3 renderChunk() will run it post-launch.
In-process behavior is unchanged — assertSwiftShader is a new pure utility.
Producer regression baselines remain byte-identical.
This is part of a stack of 10 PRs; this is PR 2 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §7.1 and §17.2 (gating table).
Adds two optional fields to EncoderOptions:
lockGopForChunkConcat?: boolean // default false
gopSize?: number // required when lockGopForChunkConcat=true
When the flag is true on the SW libx264 / libx265 paths, buildEncoderArgs
emits closed-GOP / forced-keyframe args so the resulting chunk file can be
losslessly concatenated (`ffmpeg -f concat -c copy`) with sibling chunks:
-g <gopSize>
-keyint_min <gopSize>
-sc_threshold 0
-force_key_frames "expr:eq(mod(n,<gopSize>),0)"
-x264-params "...:scenecut=0:open-gop=0:repeat-headers=1"
-x265-params "keyint=<gopSize>:min-keyint=<gopSize>:scenecut=0:open-gop=0:repeat-headers=1"
-bf 0 (added for h265 too when locked)
GPU encoders, vp9, and prores ignore the flag (their concat-copy story is
separate — see plan §7.2 / §8).
In-process behavior is unchanged: the default (false) path emits no new
args. New unit tests pin both branches in packages/engine/src/services/
chunkEncoder.test.ts.
This is part of a stack of 10 PRs; this is PR 1 of 10.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #755 added the typegpu-adapter regression test scaffolding (meta.json,
src/index.html, output/compiled.html) but left the output.mp4 golden
baseline ungenerated:
> Note: output.mp4 baseline needs to be generated in CI — the local …
Every \`regression-shards (fast)\` run since #755 merged has failed with
\`Snapshot not found: /app/packages/producer/tests/typegpu-adapter/output/output.mp4.
Run with --update to create it.\`
Generated via the canonical Docker path per CLAUDE.md:
bun run --cwd packages/producer docker:test \\
--update --suite typegpu-adapter
Stored via Git LFS (already configured for
\`packages/producer/tests/*/output/output.mp4\` in \`.gitattributes\`).
The Windows install failures (`ENOENT: failed copying files from cache to
destination for package @types/node` / `esbuild`) are caused by bun creating
workspace-scoped nested installs under
`node_modules/@hyperframes/<pkg>/node_modules/...`. Those nested paths only
exist because each workspace package pinned a different `@types/node` /
`esbuild` major:
- root: `@types/node ^25.0.10`, core: `^24.10.13`, cli/engine/producer: `^22`
- core/cli: `esbuild ^0.25.x`, producer: `^0.27.2`
Each major-version gap forces bun to install a workspace-scoped copy in a
deep `node_modules/@hyperframes/<pkg>/node_modules/<dep>/node_modules/...`
tree that bun can't reliably materialize on Windows GHA runners. Aligning
versions lets bun dedup to a single root-hoisted install per dep, and the
nested workspace block disappears from `bun.lock` entirely.
## Alignment
- `@types/node` → `^25.0.10` across root, core, cli, engine, producer
- `esbuild` → `^0.25.12` across cli, core, producer
- `tsx` → `^4.21.0` across producer (matches root + core)
## Source-level v25 compat (already in this PR)
@types/node v25 declares `File` as an interface (not a class) and exposes a
conditional global where `FormData.entries()` narrows to `[string, string]`
when an `onmessage` global is in scope. `packages/core/src/studio-api/routes/files.ts`'s
`value instanceof File` check was relying on the v24 class declaration —
already cast the iterator to `Iterable<[string, FileLike | string]>` in the
prior commit.
Two more v25 source fixes here:
- `packages/cli/src/commands/init.ts`
- `packages/cli/src/whisper/normalize.ts`
`Dirent.path` was removed in @types/node v25 (deprecated alias for
`parentPath` since Node 20.12). Drop the `?? e.path` fallback.
## Verification
Both install layouts now build clean end-to-end:
- `bun install` (isolated, default): full build green, 853 core tests pass,
typecheck green across all 7 packages
- `bun install --linker=hoisted` (Windows CI): same result
- `bun.lock` no longer contains any `@hyperframes/<pkg>/<dep>` nested
workspace entries — 70+ lines of nested install blocks gone
Pushing further to actually get Windows render verification green, not just
work around it.
## What's wrong on Windows
Bun 1.3's default `isolated` linker creates nested workspace junctions under
`packages/*/node_modules/` on Windows GHA runners. Those junctions don't
materialize reliably — Node's `realpathSync` returns `EPERM` on stat, and
ESM resolution returns `ERR_MODULE_NOT_FOUND`. Every Windows build since
PR #748 has tripped this in one of three places:
- `packages/producer/build.mjs` importing `esbuild`
- `packages/producer/scripts/generate-font-data.ts` reading `@fontsource/*`
- `packages/producer` running `tsc` to emit `.d.ts`s
Long-running bun bugs: oven-sh/bun#23615, #18354, #10146.
## Fix
**1. `--linker=hoisted` for the Windows install step** (workflow change,
Windows only). Hoisted layout puts deps as real directories at the workspace
root + workspace package node_modules. No junctions, no Windows-specific
path quirks. Linux CI keeps the default isolated linker; the lockfile is
linker-agnostic so `--frozen-lockfile` is still valid.
**2. Source-level FormData narrowing in `packages/core/src/studio-api/routes/files.ts`**
(needed because the hoisted layout exposes a `@types/node@25` typecheck
issue that the isolated layout hides). With v25 + an `onmessage` global in
scope, the ambient `FormData.entries()` infers `[string, string]` instead of
`[string, File | string]`, so the `value instanceof File` check breaks at
`TS2358`. Cast the iterator to a `[string, FileLike | string]` shape and
narrow via `typeof value === "string"`. Identical runtime behavior; works
under both v24 (isolated layout, what Linux CI sees) and v25 (hoisted, what
Windows CI sees with this change).
## Verification
- `bun install --frozen-lockfile` (isolated, default): full build green
- `bun install --frozen-lockfile --linker=hoisted`: full build green, core
typecheck passes, `@hyperframes/core` 853 tests pass
- Format/lint clean on both layouts
Two narrow fixes pulled out of a larger Windows-CI investigation:
## 1. Format check (`oxfmt`)
`packages/core/package.json` and `packages/shader-transitions/package.json`
had their `publishConfig` keys reordered to a non-canonical order by the
v0.6.1 release commit (`82fd2967`). Releases push directly to main without
going through PR CI, so the drift wasn't caught and `bun run format:check`
has been failing on every push since. Fix: re-run `oxfmt`.
## 2. `@hyperframes/cli` `build:fonts` skip-when-present
`packages/cli`'s `build:fonts` script regenerated
`packages/producer/src/services/fontData.generated.ts` unconditionally on
every cli build. The script reads `@fontsource/*` packages via
`require.resolve(...)`, which walks `packages/producer/node_modules/@fontsource/*`
junctions — these trip `EPERM: operation not permitted, stat` on Windows
GHA runners because of long-running bun-on-Windows workspace junction bugs
(see oven-sh/bun#23615, #18354, #10146).
`fontData.generated.ts` is committed to git, so the regeneration is only
needed when fonts actually change. Match the skip-when-present pattern
already in `@hyperframes/producer`'s own `build:fonts`. Doesn't fix the
Windows render verification end-to-end (the producer build itself still
trips junction issues — being tackled separately in #765), but at least
stops `cli build:fonts` from being its own failure point on Windows.
Move file-level helpers and inline blocks out of renderOrchestrator.ts
into focused render/* modules. executeRenderJob shrinks from ~897 to
~675 lines; renderOrchestrator.ts from 2725 to ~2104.
New under packages/producer/src/services/render/:
- hdrPerf.ts: HdrPerfCollector + helpers
- captureCost.ts: capture-cost + calibration helpers, plus a new
runCaptureCalibration helper that owns the BeginFrame->screenshot
fallback
- hdrMode.ts: resolveEffectiveHdrMode
- perfSummary.ts: buildRenderPerfSummary
- cleanup.ts: safeCleanup, cleanupRenderResources,
buildRenderErrorDetails
shared.ts adds createCompiledFrameSrcResolver,
materializeExtractedFramesForCompiledDir, createMemorySampler.
Moved symbols are re-exported from renderOrchestrator.ts for
backwards compatibility; tests update to import from the new paths.
No behavior change: producer smoke set is PSNR-identical to main
inside Dockerfile.test.
lefthook.yml: belt-and-suspenders fix so the filesize hook actually
skips .test.ts / .generated.ts files. The hook-level exclude regex
does not filter the staged_files expansion inside the shell loop,
so the loop now does its own check.
Resolve the compileStage TODO from PR #720. cfg.forceScreenshot is now
computed exactly once inside compileStage (after applyRenderModeHints)
and returned on CompileStageResult.forceScreenshot. The sequencer stores
it on a local captureForceScreenshot; downstream capture stages take
the value as an explicit parameter and derive their own engine config
rather than reading cfg.forceScreenshot.
Mid-pipeline mutations removed:
- renderOrchestrator.ts: the pre-compile alpha-output mutation moved
into compileStage so the resolution is one operation in one place.
- captureHdrStage.ts: stopped mutating caller-owned cfg; the layered
composite path now uses a local hdrCfg derived from cfg plus
forceScreenshot=true. The stage throws if called with
forceScreenshot=false to make the contract explicit.
- BeginFrame auto-worker calibration fallback: still flips capture mode
on a timeout, but flips the local boolean instead of cfg. The
screenshot-mode retry uses a derived cfg view.
captureStage / captureStreamingStage add a forceScreenshot input and
derive captureCfg (identity-equal to cfg when the values already
agree, so no extra allocation on the common path).
lefthook.yml: grandfather renderOrchestrator.ts and captureHdrStage.ts
in the new 500-line filesize hook (#748). Both pre-date the hook and
are actively being shrunk in the producer stages stack.
Unblocks Phase 3 chunked rendering: LockedRenderConfig.forceScreenshot
in the distributed plan is computed here and survives across processes
without depending on shared mutable state.
First of several focused PRs that flatten the runtime cycle between
the capture stages and `renderOrchestrator.ts` (documented as a known
follow-up across PRs 1.6 / 1.7 / 1.8 / 1.9).
`updateJobStatus` was the most-imported orchestrator helper: 5 of the
6 capture / encode / assemble stages reach back into the orchestrator
for it. Moving it to `render/shared.ts` (where the other small
cross-cutting utilities already live) breaks the runtime cycle for
five stages in one move:
- captureStage
- captureStreamingStage
- captureHdrStage
- encodeStage
- assembleStage
Each of those stages now imports `updateJobStatus` from `../shared.js`
at runtime, and the only thing they pull from `renderOrchestrator.js`
is type-only (`RenderJob`, `ProgressCallback`, etc.) — type imports
are erased at runtime, so no cycle.
The orchestrator's own internal call sites (`updateJobStatus(...)` for
the inline progress updates and the `complete` / `failed` / `cancelled`
transitions) are unchanged in body; they now import the function from
the same shared module.
Follow-up PRs will move:
- `executeDiskCaptureWithAdaptiveRetry` + capture-retry helpers (breaks
the captureStage cycle entirely)
- The six HDR helpers + `resolveCompositeTransfer` (breaks captureHdrStage)
- `collectVideoMetadataHints`, `collectVideoReadinessSkipIds`,
`materializeExtractedFramesForCompiledDir` (breaks extractVideosStage)
No behavior change. Verified inside `Dockerfile.test`:
font-variant-numeric, many-cuts, gsap-letters-render-compat,
hdr-regression — 4/4 PASS with identical audio correlations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment + interface cleanup driven by the /simplify review. No code
change beyond removing dead fields.
- audioStage: drop unused `job: RenderJob` from `AudioStageInput` (the
stage destructures it but never references the value).
- encodeStage: drop unused `fps` + `useGpu` from `EncodeStageInput`;
read both from `job.config.*` inside the stage (matches the pattern
used by captureStage and captureStreamingStage).
- captureStreamingStage: drop the unused `captureDurationMs` field
from `CaptureStreamingStageResult` (sequencer never reads it — it
uses its own `Date.now() - stage4Start` for `perfStages.captureMs`).
Also drops the now-dead `streamStart` local.
- captureStreamingStage: rewrite the "Known follow-up" header comment
to drop the "PR 1.3.5" reference per `feedback_no_internal_track_names_in_source`.
- captureHdrStage: drop the "Lifted verbatim from `executeRenderJob`"
refactor-narration sentence in the header doc (the "Hard constraints
preserved verbatim" list below it is real long-term documentation
and stays).
- Sequencer call sites updated to drop the now-removed fields.
Verified inside `Dockerfile.test`: 4/4 fixtures pass with PSNR / audio
correlations unchanged (font-variant-numeric, many-cuts, gsap-letters,
hdr-regression).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final polish PR of the Phase 1 stack. Comment-only — zero code change.
After PRs #725, #726, #730, #731, #733, #734, the `executeRenderJob`
function now composes eight stage modules instead of inlining the
pipeline. Updates the file-level JSDoc to point at each stage module
and explains the orchestrator's residual responsibilities: shared
resource lifetime, perf counters, error diagnostics, and the
`try/finally` cleanup. Adds JSDoc on `executeRenderJob` itself
summarising what it returns and when it throws.
The function body is unchanged. The line count dropped from ~2,200
(pre-Phase-1) to ~880; the remainder is in-sequencer setup that doesn't
naturally compose into a stage (calibration, worker resolution, HDR
auto-detection, preset selection, final perf-summary assembly) plus
the orchestrator's `try/finally` resource ownership.
Verified inside `Dockerfile.test`: font-variant-numeric (1.000),
many-cuts (0.994), variables-prod (0.975), hdr-regression (1.000) —
4/4 PASS with audio correlations identical to every prior PR in the
Phase 1 stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the final two stages of `executeRenderJob` into their own files:
- `services/render/stages/encodeStage.ts` (Stage 5): handles both the
png-sequence path (rename + copy + audio sidecar) and the encoded path
(`encodeFramesFromDir` or `encodeFramesChunkedConcat`).
- `services/render/stages/assembleStage.ts` (Stage 6): runs
`muxVideoWithAudio` when `hasAudio`, otherwise `applyFaststart`.
Skipped for png-sequence (sequencer gates the call).
Both stages are mechanical extractions of small, self-contained blocks.
The sequencer's call sites preserve the same conditions and the same
`perfStages.encodeMs` / `perfStages.assembleMs` assignments.
Hard constraints preserved verbatim:
- The `updateJobStatus` payloads ("Writing PNG sequence" / "Encoding
video" at 75%; "Assembling final video" at 90%) fire from inside the
stages at the same code points.
- The png-sequence "no PNGs were captured" error throws verbatim.
- The png-sequence audio sidecar is only written when
`hasAudio && existsSync(audioOutputPath)`.
- `enableChunkedEncode` selects `encodeFramesChunkedConcat` vs.
`encodeFramesFromDir` with the same args.
- The mux + faststart error messages (`Audio muxing failed: ...`,
`Faststart failed: ...`) throw verbatim on `success: false`.
Removes the now-orphaned imports from the orchestrator:
`encodeFramesFromDir`, `encodeFramesChunkedConcat`, `muxVideoWithAudio`,
`applyFaststart`.
Verified inside `Dockerfile.test`:
- font-variant-numeric (1.000), many-cuts (0.994),
sub-composition-video (0.947), gsap-letters-render-compat (1.000),
hdr-regression (1.000) — 5/5 PASS, audio correlations identical to
prior PRs in the stack. Exercises encoded mp4 + HDR (encode + assemble
both run) and the streaming-fusion path (encode skipped by sequencer).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the Z-ordered HDR / shader-transition layered composite branch
(`if (useLayeredComposite)`) out of `executeRenderJob` into
`services/render/stages/captureHdrStage.ts`. The largest extraction by
LOC (~745 lines of body lifted verbatim) and the riskiest by cleanup
invariants. Body is lifted byte-for-byte — only the surrounding scope
changes.
Cleanup invariants preserved verbatim (design doc §11 flagged these
explicitly):
- `hdrEncoderClosed` / `domSessionClosed` flags gate the
defensive-close paths so they don't run twice when the success path
already closed.
- `hdrVideoFrameSources` is drained + cleared in the outer `finally`
regardless of how the body exited.
- `cfg.forceScreenshot = true` is set unconditionally inside the
layered path because `captureAlphaPng` hangs under
`--enable-begin-frame-control`.
Other invariants preserved:
- `hdrPerf` is created at the top of the stage and returned; the
sequencer's `finalizeHdrPerf` consumes it for the perf summary.
- The `Layered compositing frame N/M` `updateJobStatus` payload fires
at the same per-frame point with `25 + frameProgress * 55`.
- `composition` and `compiled` are read-only in the stage.
- `hdrDiagnostics` is mutated in place (counters incremented at the
same code points).
- `nativeHdrIds` is recomputed inside the stage from
`nativeHdrVideoIds` + `nativeHdrImageIds` (the sequencer's
computation is unchanged; the stage just doesn't need it passed in).
To support the extraction, the following symbols are newly exported
from `renderOrchestrator.ts`:
- Helper functions: `createHdrPerfCollector`, `addHdrTiming`,
`closeHdrVideoFrameSource`, `blitHdrVideoLayer`, `blitHdrImageLayer`,
`compositeHdrFrame`.
- Types: `HdrPerfCollector`, `HdrPerfTimingKey`, `HdrVideoFrameSource`,
`HdrImageBuffer`, `HdrCompositeContext`, `HdrTransitionMeta`,
`TransitionRange`.
These are internal helpers — the stage is currently the only consumer,
and the cycle (orchestrator imports `runCaptureHdrStage`; stage imports
helpers back) is safe at runtime. A future PR will consolidate the
helpers into a shared module (same follow-up planned for the capture
helpers in PRs 1.6 and 1.7).
Removes the now-orphaned imports from the orchestrator:
`openSync`, `fpsToFfmpegArg`, `spawnStreamingEncoder`,
`StreamingEncoder` type, `runFfmpeg`, `initTransparentBackground`,
`decodePngToRgb48le`, `queryElementStacking`, `TRANSITIONS`,
`crossfade`, `resampleRgb48leObjectFit`, `normalizeObjectFit`,
`TransitionFn` type, `createHdrImageTransferCache`.
Verified inside `Dockerfile.test`:
- **HDR fixtures (3/3 PASS)**: hdr-regression, hdr-hlg-regression,
vignelli-stacking — audio correlations 1.000 / 1.000 / 0.982.
- **Non-HDR fixtures (4/4 PASS)**: font-variant-numeric, many-cuts,
sub-composition-video, gsap-letters-render-compat — audio
correlations 1.000 / 0.994 / 0.947 / 1.000.
- 7/7 fixtures total pass with PSNR / audio correlations matching every
prior PR in the stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the streaming encode fusion path (`useStreamingEncode === true` with
successful encoder spawn) out of `executeRenderJob` into
`services/render/stages/captureStreamingStage.ts`. The stage owns:
- `spawnStreamingEncoder` invocation, including the abort-rethrow vs.
graceful-fallback handling.
- Parallel + sequential capture-to-stdin loops (Stage 4 absorbs Stage 5
for streaming renders).
- The streaming encoder's `close()` + result check.
- Defensive cleanup of the streaming encoder in the stage's own
`try/finally`.
The stage returns either `{ success: true, ... }` (sequencer skips the
disk path AND inline Stage 5) or `{ success: false }` (sequencer falls
back to the disk path). The sequencer's `useStreamingEncode` flag is
no longer flipped imperatively — the result type makes the branch
selection explicit.
Hard constraints preserved verbatim:
- `probeSession` is closed at the same code points (parallel: after
capture; sequential: in session finally). The local binding nulls
via the returned result.
- `lastBrowserConsole` is set to the buffer of whichever session was
active last (probe close path or sequential session finally).
- `job.framesRendered` is updated per-frame; `Streaming frame N/M
[(K workers)]` `updateJobStatus` payloads fire at the same 30-frame
and completion checkpoints (parallel) or every frame (sequential),
with the same percentage math `25 + frameProgress * 55`.
- `Streaming encode failed: <err>` still throws on the encoder's
`success: false` close result.
- The defensive `try/finally` close-on-throw is preserved, now inside
the stage instead of the orchestrator.
- `perfStages.captureMs` is still set by the sequencer from
`stage4Start`; the stage also returns `encodeMs` for the encoder's
overlapped duration (assigned to `perfStages.encodeMs`).
Removes the orphaned `createFrameReorderBuffer` and
`prepareCaptureSessionForReuse` imports from the orchestrator after
the streaming code moved.
Verified inside `Dockerfile.test`:
- 5/5 fixtures PASS (font-variant-numeric, many-cuts, variables-prod,
sub-composition-video, gsap-letters-render-compat).
- `gsap-letters-render-compat` (single-worker render, 4s duration)
exercises the new streaming stage end-to-end —
`streaming-encode gate enabled=true` confirmed in the log.
- The other 4 fixtures exercise the disk path (workerCount > 1).
Known follow-up: same runtime import cycle situation as captureStage —
the stage imports `updateJobStatus` and types from
`renderOrchestrator.ts`, which imports the stage back. Safe (deferred
to runtime); a future PR will flatten this once all 8 stages are
extracted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the SDR / DOM-only-HDR disk-capture body out of `executeRenderJob`
into `services/render/stages/captureStage.ts`. Covers both branches of
the disk path: parallel capture via `executeDiskCaptureWithAdaptiveRetry`
(`workerCount > 1`) and sequential per-process capture (`workerCount === 1`,
reusing `probeSession` when available).
The HDR layered branch (`useLayeredComposite === true`) and the streaming
encode fusion path (`useStreamingEncode === true` with successful encoder
spawn) stay inline in the sequencer — they will be extracted by the next
two PRs in the stack.
Hard constraints preserved verbatim:
- `probeSession` is closed (and the sequencer's `let probeSession`
nulled via the returned result) at the same points.
- `captureAttempts` is mutated in place — the parallel retry loop still
pushes each attempt onto the array the sequencer owns.
- `workerCount` reassignment from adaptive retry survives via the
returned result.
- `lastBrowserConsole` is set to the buffer of whichever session was
active last (probe close path or sequential capture finally).
- `job.framesRendered` is updated at the same per-frame / per-progress
points; `Capturing frame N/M [(K workers)]` `updateJobStatus` payloads
fire at the same 30-frame and completion checkpoints.
- `perfStages.captureMs` is still computed by the sequencer from the
outer `stage4Start` so its window covers both the in-sequencer setup
(fileServer init, calibration, worker resolution, preset selection)
AND the capture call.
Two small new exports on `renderOrchestrator.ts`:
- `executeDiskCaptureWithAdaptiveRetry` — was a private helper; the
stage calls it directly.
- `updateJobStatus` — was a private helper; the stage uses it for the
per-frame progress callbacks so the `completedAt` branch matches.
These re-introduce a small runtime cycle between the stage and the
orchestrator (orchestrator imports `runCaptureStage`; stage imports
helpers back). The cycle is safe (both modules finish loading before
any stage function is invoked at runtime) and will be flattened in a
follow-up PR that consolidates capture helpers into a shared module.
Removes the now-orphaned `captureFrame` import from the orchestrator.
Verified inside `Dockerfile.test`:
- `font-variant-numeric`: audio correlation 1.000
- `many-cuts`: 0 failed frames, audio correlation 0.994
- `variables-prod`: PSNR ~69 dB, audio correlation 0.975
- `sub-composition-video`: PSNR ~43-52 dB, audio correlation 0.947
(exercises video extraction + capture end-to-end)
- `gsap-letters-render-compat`: PSNR ~53-55 dB, audio correlation 1.000
(exercises the parallel capture path; 5/5 PASS overall)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the audio mixing sub-stage of `executeRenderJob` into
`services/render/stages/audioStage.ts`. Trivial wrapper around
`processCompositionAudio` with the same skip-when-empty path.
No behavior change:
- `audioOutputPath` is still `join(workDir, "audio.aac")` regardless of
whether the composition has audio.
- `hasAudio` still reflects `audioResult.success` (false when no audio
elements or when the mixer returns success: false).
- `perfStages.audioProcessMs` is set at the same end-of-stage point
whether or not the mixer ran.
- The "Processing audio tracks" progress callback fires at 20% at the
same code point.
Removes the now-unused `processCompositionAudio` import from the
orchestrator (oxlint flagged it).
Verified inside `Dockerfile.test` against `font-variant-numeric`,
`many-cuts`, `variables-prod` — 3/3 pass with audio correlations
1.000 / 0.994 / 0.975 (identical to prior PRs in the stack).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the video frame extraction sub-stage out of `executeRenderJob` into
`services/render/stages/extractVideosStage.ts`. The stage covers HDR
color-space pre-detection for videos and images, the
`extractAllVideoFrames` call, frame-lookup-table construction, video
readiness skip-id collection, video metadata hints, and the auto-detect
of audio tracks from video files.
Hard constraints preserved verbatim:
- `composition.audios` is still mutated in place to add audio entries
auto-discovered from video files via ffprobe.
- `perfStages.videoExtractMs` is set at the same end-of-stage point.
- `materializeExtractedFramesForCompiledDir` is still called once when
`extractionResult.extracted` is non-empty.
- `force-sdr` mode still skips ALL ffprobe overhead.
New for distributed mode (`materializeSymlinks: boolean`, default false):
- Plumbs through to `materializeExtractedFramesForCompiledDir` via a new
option of the same name. When `true`, the helper invokes
`cpSync(recursive)` instead of `symlinkSync` so the staged frames are
real files inside `compiledDir`. Symlinks don't survive S3 / GCS
round-trips, so distributed `plan()` will pass `true` once it lands.
Default `false` preserves the in-process renderer's symlink behavior.
- New unit test covers the copy path and asserts `symlinkSync` is NOT
invoked; the existing symlink test was updated with the parallel
guard that `cpSync` is NOT invoked.
Removes the imports the orchestrator no longer needs after the
extraction: `extractAllVideoFrames`, `resolveProjectRelativeSrc`,
`createFrameLookupTable`, `FrameLookupTable`, `detectTransfer`,
`isHdrColorSpace`, `extractMediaMetadata`, `VideoColorSpace` (oxlint
flagged each).
Verified:
- `bunx oxlint` + `bunx oxfmt --check` clean
- `bun run --filter @hyperframes/producer typecheck` + `build` clean
- `bun test packages/producer/src/services/` — 176 pass, 1 pre-existing
unrelated failure
- `docker run hyperframes-producer:test` against `font-variant-numeric`,
`many-cuts`, `variables-prod`, `sub-composition-video` — 4/4 PASS with
correlations 1.000 / 0.994 / 0.947 / 0.975 (sub-composition-video is
the one that exercises video frame extraction end-to-end)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small follow-ups on the shared.ts extraction, addressing review
feedback on #720:
- Hoist `BROWSER_MEDIA_EPSILON` from `probeStage.ts` into `shared.ts`
so any future stage that reconciles browser media (chunked rendering
re-probe, for instance) doesn't have to redeclare it.
- Migrate `renderOrchestrator.test.ts` to import the five moved symbols
(`applyRenderModeHints`, `projectBrowserEndToCompositionTimeline`,
`resolveDeviceScaleFactor`, `writeCompiledArtifacts`,
`CompositionMetadata`) directly from `./render/shared.js`. This is
the clean end state — the back-compat re-export through
`renderOrchestrator.ts` was a stepping-stone.
- Drop the back-compat re-export block from `renderOrchestrator.ts`.
No remaining importers go through it (verified via grep across
`packages/`). The five symbols now have exactly one path:
`./render/shared.js`.
No behavior change. Renderer smoke-tested inside `Dockerfile.test`
against `font-variant-numeric`, `many-cuts`, and `variables-prod` —
audio correlations 1.000 / 0.994 / 0.975, matching every prior PR in
the stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Breaks the runtime circular import between `renderOrchestrator.ts` and
the stage files under `services/render/stages/`. Before this change the
stages imported runtime helpers (`writeCompiledArtifacts`,
`applyRenderModeHints`, `resolveDeviceScaleFactor`,
`projectBrowserEndToCompositionTimeline`) and types (`CompositionMetadata`)
back from `renderOrchestrator.ts`, which itself imports the stage
functions. The cycle resolved at build time because both modules
finished initializing before any stage was invoked, but it was fragile
and would keep growing as more stages were extracted.
This PR:
- Adds `packages/producer/src/services/render/shared.ts` and moves the
four functions plus the `CompositionMetadata` interface into it.
- Has `renderOrchestrator.ts` re-export everything from `shared.ts`, so
external callers (the existing `renderOrchestrator.test.ts`, any code
importing `applyRenderModeHints` etc. from the orchestrator) keep
working with no churn on their side.
- Updates `compileStage.ts` and `probeStage.ts` to import the runtime
helpers from `../shared.js`. The only remaining import from
`renderOrchestrator.ts` in the stages is `import type { RenderJob }`,
which is erased at runtime and creates no cycle.
- Removes the imports the orchestrator no longer needs after losing
the four function definitions (`CANVAS_DIMENSIONS`, `VideoElement`,
`AudioElement`, `ImageElement`).
No behavior change. Renderer smoke-tested inside `Dockerfile.test`
against `font-variant-numeric`, `many-cuts`, and `variables-prod` —
all PSNR / audio-correlation baselines match PR 1.3 exactly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The probe stage previously assigned `job.duration` and `job.totalFrames`
inside its body AND the sequencer re-asserted them after the call to
restore TS narrowing. Two writers for the same field is a maintenance
hazard — a future refactor could drop one and create a silent skew.
Move ownership: the stage computes `duration` and `totalFrames` and
returns them; the sequencer is the sole writer onto the `RenderJob`.
This also aligns with the eventual chunk-worker model where a chunk
running in a separate process cannot mutate the orchestrator's `job`.
No observable behavior change. `job.duration` / `job.totalFrames` end
up with the same values; the zero-duration `throw` still happens
inside the stage (now using the local `duration` constant) before any
sequencer-side assignment. Verified by:
- `bun run --filter @hyperframes/producer typecheck` clean
- `bun test packages/producer/src/services/` 175 pass / 1 pre-existing
unrelated failure on `main`
Review feedback addressed: vanceingalls on #719.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment-only cleanup. Removes "PR 1.x", "Phase 1 PR", and "Phase 3 PR 3.1"
references from JSDoc blocks in `compileStage.ts`, `probeStage.ts`,
`planHash.ts`, and `freezePlan.ts`. Track / PR identifiers rot quickly and
belong in PR descriptions, not in source. Design-doc section citations
(DISTRIBUTED-RENDERING-PLAN.md §X.Y) are kept — those reference a stable
external artifact.
Also tightens the `probeStage.ts` `browserProbeMs` doc string to say
"near-zero when `needsBrowser` was false" instead of "0" — the Date.now()
delta around the function body is sub-ms but not literally zero.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the browser probe / duration discovery / recompile / media
reconciliation block out of `executeRenderJob` into
`services/render/stages/probeStage.ts`. No behavior change. The sequencer
calls `runProbeStage` at the same code point with identical inputs and
outputs.
The probe stage owns the `FileServerHandle` and the `CaptureSession` it
creates and returns them to the sequencer. The sequencer still tracks
them in its `let fileServer` / `let probeSession` bindings and closes
them in its `finally` block — the resource lifetime is unchanged.
`recompileWithResolutions` lives inside this stage because it depends on
browser-resolved durations even though §2.1 of the distributed plan
lists recompile as a sibling phase.
Preserved invariants:
- `composition` is mutated in place (videos / audios / duration) so
downstream stages see the reconciled view through the same reference.
- `job.duration` and `job.totalFrames` end up with the same values at
the same code points. The result type carries `duration: number`
alongside `totalFrames: number`, and the sequencer re-asserts the
assignments after the call so TypeScript's control-flow narrowing
works for the rest of `executeRenderJob`.
- `perfStages.browserProbeMs` and `perfStages.compileMs` are written at
the same code points with the same values.
- The "Composition duration is 0" diagnostic builds the same hint string
from the same console-buffer regex and `__timelines` probe.
- The post-probe "failed network requests" warning fires with the same
regex, the same first-10/first-5 slicing, and the same `console.warn`
prefix.
Renderer smoke-tested inside `Dockerfile.test` against `font-variant-numeric`,
`many-cuts`, and `variables-prod` — all PSNR / audio correlation baselines
match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a `TODO(distributed-render):` comment near the `applyRenderModeHints`
call documenting that this caller-owned-object mutation needs to move
into the result type before `freezePlan` wires up. The mutation pattern
works in-process but won't survive across processes / replays from a
frozen plan — the value belongs in `LockedRenderConfig`, not on a
mutated `EngineConfig`.
No behavior change. Comment-only.
Review feedback addressed: vanceingalls on #718.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the pure compile sub-stage (`compileForRender` + `applyRenderModeHints`
+ `writeCompiledArtifacts` + `CompositionMetadata` build + DPR resolution)
out of `executeRenderJob` into `services/render/stages/compileStage.ts`.
No behavior change. The sequencer calls `runCompileStage` at the same code
point with identical inputs and outputs. The following invariants are
preserved verbatim:
- `cfg.forceScreenshot` is still mutated by `applyRenderModeHints`.
- `perfStages.compileOnlyMs` is set to the same wall-clock interval (around
the `compileForRender` call only).
- The "Compiled composition metadata" log line is emitted after artifact
writes with the same payload shape.
- The "Supersampling composition via deviceScaleFactor" log line is emitted
only when `deviceScaleFactor > 1`.
- `stage1Start`, `updateJobStatus(..., "Compiling composition", 5, ...)`,
and `perfStages.compileMs` (set at the end of probe) remain at their
current code points in the sequencer.
The probe sub-stage (`if (needsBrowser)`) is unchanged — it is extracted
separately in PR 1.3. `recompileWithResolutions` lives inside the probe
block because it depends on browser-resolved durations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add `PLAN_HASH_SCHEMA_PREFIX = "hyperframes-plan-hash-v1\x00"` mixed
into every digest. Future framing changes must bump the trailing
integer; this makes cross-version mismatches visible at the wire
format instead of producing silent collisions. Impossible to backfill
later, easy to bake in now.
- Hoist the `0x00` field delimiter to module scope (`FIELD_DELIMITER`).
- Document the UTF-8 encoding contract for all string-typed input
fields in the file-level JSDoc, so external verifiers know the
encoding without reading the implementation.
- Add a known-digest test for one fixed reference input
(`995b4105...`). If the framing changes silently this test fails,
forcing the developer to also bump the schema prefix.
- Add an explicit `canonicalJsonStringify(undefined) → TypeError` test
to pin the contract.
No callers yet, so no behavior change in any code path.
Review feedback addressed: vanceingalls on #717.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seed PR for the Phase 1 staged refactor of `executeRenderJob`. Creates the
new `packages/producer/src/services/render/stages/` directory and adds two
files that subsequent stage-extraction PRs build on:
- `planHash.ts`: a content-addressed sha256 helper plus a canonical-JSON
serializer, with unit tests covering determinism, asset-order
independence, sensitivity to each hashed component, and a delimiter-
framing test against path/sha boundary collisions.
- `freezePlan.ts`: signature-only skeleton (throws "not implemented") for
the eventual plan-freeze step. No callers; the body lands later when the
distributed-render primitives compose the Phase 1 stages.
Zero behavior change. No code in `executeRenderJob` is touched and no
existing exports move. The new files are not yet referenced anywhere
outside the `stages/` directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The four existing presets only cover 16:9 (landscape) and 9:16 (portrait)
aspect ratios. A 1080×1080 square comp had nowhere to land at any scale:
"Auto" rendered at the comp's authored 1080×1080, and picking 1080p or 4K
mapped to a landscape/portrait preset whose aspect ratio mismatched, which
the producer's resolveDeviceScaleFactor validator rejects with
"does not match the aspect ratio of the composition".
Add `square` (1080×1080) and `square-4k` (2160×2160) to CANVAS_DIMENSIONS
in core. The existing `keyof typeof CANVAS_DIMENSIONS` derivation
extends the `CanvasResolution` union and `VALID_CANVAS_RESOLUTIONS` array
automatically, so the producer's validator, the render API route, and
the CLI `--resolution` flag pick the new presets up without further
changes.
- core: extend CANVAS_DIMENSIONS, RESOLUTION_ALIASES, and the
htmlParser to recognize `data-resolution="square|square-4k"` and to
infer square from equal width/height (vs. the prior "square defaults
to portrait" tie-breaker).
- studio: extend the local ResolutionPreset / CANVAS_DIMENSIONS mirrors;
collapse isPortraitComp into a 3-way `compAspect` helper so
resolveResolution returns the square preset for square comps.
- cli: update --resolution help text on `init` and `render` to mention
the new presets.
- tests: add square cases to renderOrchestrator's resolveDeviceScaleFactor
suite (returns 1 for square→square, 2 for square→square-4k, rejects
landscape preset on square comp), update the htmlParser test that
previously pinned the "square→portrait" tiebreaker.
Cleanup from the /simplify pass on PR #715.
- App.tsx: subscribe to the runtime's `stage-size` message (which
carries authoritative width/height post-applyCompositionSizing)
instead of re-parsing data-width/data-height from the iframe DOM.
Drops the cross-origin try/catch, querySelector, and parseInt logic,
and fires once per comp load instead of on every state/timeline tick.
- App.tsx: import CompositionDimensions from RenderQueue instead of
inlining the shape.
- RenderQueue.tsx: replace scaleLabel() with a SCALE_LABEL record,
inline the one-call formatDims helper, and trim the type comment to
the WHY.
Two bugs in getSharedBrowser() could take down the entire Vite dev
server:
1. Unhandled rejection from puppeteer.launch() — the timeout error
surfaces through puppeteer's internal RxJS chain, and any uncaught
path crashes the Node process. The thumbnail route's try/catch
doesn't always intercept it.
2. _browserLaunchPromise was never reset on failure, so subsequent
thumbnail requests reused a stale rejected promise instead of
retrying.
Wrap the IIFE in try/catch, return null on any failure (the thumbnail
route already handles a null adapter result with a 500), and reset
_browserLaunchPromise in a finally block so a transient launch failure
doesn't poison the singleton. Also drop the launch timeout from
puppeteer's 30s default to 10s so a wedged handshake fails fast instead
of stalling every pending thumbnail.
Verified locally: the dev server now logs
"[Studio] puppeteer launch failed — thumbnails disabled: ..." and
keeps serving the studio UI after a thumbnail request fails.
Orientation is a property of the composition, not a user choice — the
backend's portrait/landscape presets are tied to the comp's authored
aspect ratio. Letting users pick "1080p portrait" for a landscape
composition just produces a wrong-aspect render.
The dropdown now exposes three scale choices (Auto / 1080p / 4K) and
maps to the correct portrait/landscape preset based on the active
composition's data-width / data-height. Native <select title> tooltips
are unreliable across browsers, so the resolved dimensions render
inline in each option label (e.g. "1080p · 1920×1080") — always
visible, no hover needed.
App.tsx tracks the active comp's dimensions by listening for the
existing hf-preview state/timeline postMessages (same source the
caption-detection logic uses) and passes them to RenderQueue. The
useRenderQueue / backend contract is unchanged: RenderQueue still emits
"landscape" | "portrait" | "landscape-4k" | "portrait-4k" | "auto".
- Hoist duplicated test mock helpers (createMockAudioContext / setupTransport /
mockBuffer / mockEl) from the two describe blocks to module scope.
- Drop redundant math-derivation comments in schedulePlayback; the dedicated
rate-aware tests are the canonical proof.
- Tighten setRate JSDoc.
- Add no-op guard in setRate when the new rate equals the current rate, so a
duplicate set-playback-rate postMessage doesn't re-anchor or walk active
sources for nothing.
- Add a regression test for the no-op guard, and strengthen the clamp test
to schedule at rate=2 first so the clamp-to-1 assertion is non-vacuous.
WebAudioTransport scheduled AudioBufferSourceNodes with the implicit
default playbackRate of 1, so non-1x transport rates desynced visuals
from audio: GSAP timelines, the transport clock, and native <video>
all sped up while WebAudio-routed <audio> clips kept playing at 1x.
- schedulePlayback now accepts a rate, sets sourceNode.playbackRate,
and scales the future-clip start delay by the rate (the in-progress
buffer offset stays elapsed + mediaStart, which is rate-independent).
- New setRate() updates active sources in place and rebases the
getTime() reference frame so the audio-master clock stays continuous
across mid-playback rate changes.
- Runtime onSetPlaybackRate now forwards into webAudio.setRate, and
player.play() schedules each clip with state.playbackRate.
Fixes#713
Adds a new docs page under Getting Started that links to the
heygen-com/hyperframes-launches repo — open-source HyperFrames
compositions behind HeyGen's product launch videos. Includes a brief
catalog of the 5 projects currently in there, framing on why these are
useful (multi-composition shape, real adapter mix, production-grade
timing), and the LFS-aware clone recipe.
Cross-linked from `docs/examples.mdx`'s Next Steps and from
`docs/community/adopters.mdx`.
Came from a Discord ask via blackNoir (forwarded by James) — users
landing on the docs want to see how the internal team builds their own
videos through HyperFrames; this surfaces that source in one click.
The agent skill loader rejects SKILL.md files whose frontmatter description
exceeds 1024 characters, so remotion-to-hyperframes was being skipped at
startup with a "exceeds maximum length of 1024 characters" warning.
Trimmed the description from 1240 to 896 characters by collapsing the
trigger-phrase examples and tightening prose, while preserving every
trigger / no-trigger guardrail. Moved the detailed list of trigger phrases
and the 4 negative cases into a new "## When to use" section in the body
so the guidance is not lost.
Fixes#688
GitHub strips autoplay/loop from <video> tags in markdown, so the MP4
required a click to play. WebP autoplays via <img> while preserving full
HD source quality (1280x720 vs the previous GIF's 400x225).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The autobuild-2026-04-23-13-16 release was rotated out of BtbN's
recent-dailies window, returning 404 on the Windows test/render jobs.
Switch to the 2026-04-30 month-end snapshot, which BtbN keeps long-term
(visible in the persistent monthly-snapshot history).
The first dynamic `await import("./render.js")` cold-load takes >5 s on
Windows runners — long enough to blow vitest's default 5 s timeout in
whichever test ran it first. Subsequent imports are <10 ms because the
module is now cached, so only test #1 ever times out.
The downstream failure is more subtle: when test #1 times out, vitest
moves on, but its leaked async function eventually hits the synchronous
`producer.createRenderJob(...)` line and pushes a stale config to
`producerState.createdJobs`. That push lands AFTER test #2's `beforeEach`
clears the array, so test #2's `createdJobs[0]` is the leaked test #1
entry instead of its own. That's why test #2 saw `browserGpuMode: 'software'`
when it expected `'auto'`.
Hoist the import into `beforeAll` (matching the pattern the existing
`parseVariablesArg` and `validateVariablesAgainstProject` describe blocks
in this file already use). Cold-load happens once outside any test's
timeout window, every test stays fast, no leaked promise can corrupt
state.
Failing run: https://github.com/heygen-com/hyperframes/actions/runs/25470257972/job/74732502915
Started failing on main with the merge of #642 (auto-detect-browser-gpu),
which added the "forwards browserGpuMode='auto'" test as test #2.
Drive-by fix: hf#631 (composition flag) merged with two test calls
using `browserGpu: false`, but hf#642 (browserGpuMode auto) merged
shortly after and removed that field from RenderOptions in favour of
the tri-state `browserGpuMode`. Main has been failing typecheck since
hf#642 landed (every PR inherits the failure).
Renaming `browserGpu: false` → `browserGpuMode: "software"` matches
the new shape; both tests still verify what they were written for
(forwards entryFile / omits entryFile to createRenderJob).
After hf#641 inlined the runtime IIFE into every bundle, lint tools
inspecting bundled output (including Abhay's c2v eval) started flagging
empty `catch {}` blocks across the runtime. The source had explanatory
comments inside, but esbuild's minifier strips them — the IIFE ships
~10 visible patterns of `}catch{}` and consumers' linters fire on each.
Each empty catch is intentional best-effort error swallowing —
postMessage to a parent frame that may not exist, `media.play()` /
`pause()` that throw under autoplay restrictions, timeline `seek()` on
a disposed timeline, anime.js / lottie feature detection on hosts that
don't load those libraries, etc. The right behaviour stays "tried,
didn't work, move on", but doing it visibly improves three things:
- lint clean: helper call is a real statement; no `no-empty` warnings
survive minification
- debuggable: flip `window.__hfDebug = true` in DevTools to see every
swallow site with `console.debug` (silent in prod by default)
- observable: studio / embeddings can install
`window.__hf.onSwallowed = handler` to collect runtime swallow
events without polluting the page console
Implementation: `packages/core/src/runtime/diagnostics.ts` exports
`swallow(label, err?)`. 41 catch sites across 12 runtime files
converted via mechanical pass (auto-generated `runtime.<module>.siteN`
labels — labels can be tightened site-by-site as a follow-up; the
shape of the change is what matters here).
Verification:
- core 674/674 (incl. 6 new diagnostics tests covering silent default,
__hfDebug logging, legacy __HYPERFRAMES_DEBUG flag, handler hook,
handler-throws-doesn't-recurse, both-active)
- typecheck clean
- format / lint clean
- runtime IIFE rebuilds successfully (`bun run build:hyperframes-runtime`)
Refs Abhay's c2v eval — bundler artefacts now lint-clean with the
runtime body inlined.