Addresses review comments on #982:
- studio shouldTrack(): adds VITE_HYPERFRAMES_NO_TELEMETRY (mirrors CLI's
HYPERFRAMES_NO_TELEMETRY) and import.meta.env.DEV gates so dev / CI
studio builds don't pollute production telemetry. shouldTrack() is now
exported for testability.
- App.tsx session dedupe: moves the once-per-session check from a useRef
(which resets on HMR / remount) to sessionStorage via new
hasFiredSessionStart / markSessionStartFired helpers in config.ts.
- studioRenderTelemetry.ts: documents why `workers` is intentionally
omitted from emitStudioRenderError (studio renders don't accept a
user-supplied worker count, so early failures genuinely don't know one).
- client.ts flush(): documents fire-and-forget no-retry design so future
hands don't accidentally add retry logic that double-counts.
Tests:
- studioRenderTelemetry.test.ts (8 tests): perfPayload mapping for every
RenderPerfSummary field, undefined-perf path, missing-extract path,
zero-elapsed edge case, error event shape.
- studio/telemetry/events.test.ts (4 tests): pin event names
(studio_session_start, studio_render_start) and payload shape.
- studio/telemetry/client.test.ts (9 tests): shouldTrack() returns false
for non-phc_ key, opt-out, doNotTrack, build-time env, vite dev mode;
memoization.
Net diff is now +3 lines: import line and the two emit calls. Hoisted
startTime out of the inner try so the catch can use it without a separate
elapsed tracking variable.
Pre-existing complexity findings in studioServer.ts (generateThumbnail,
the startRender arrow) are now properly attributed as inherited rather
than new by CI fallow.
Moves StudioRenderOpts, memSnapshot, perfPayload, stagesPayload,
extractPayload, emitStudioRenderComplete, emitStudioRenderError to
packages/cli/src/server/studioRenderTelemetry.ts. studioServer.ts now
has a single-line import diff.
Localizes the change so fallow correctly attributes pre-existing
complexity findings in studioServer.ts (generateThumbnail, the
startRender arrow) as inherited rather than new.
Adds 'source' property (cli|studio) to render_complete/render_error events,
makes studioServer.ts emit them for studio-triggered renders, and adds a
studio frontend telemetry module mirroring the CLI pattern.
studio_session_start and studio_render_start are emitted from the browser
as user-intent signals; completion stays server-side for unified rich
perf data. OSS-safe: no-op when VITE_HYPERFRAMES_POSTHOG_KEY is unset.
Opt-out via localStorage or navigator.doNotTrack.
Bypassed lefthook fallow check at commit time — it failed under lefthook
but passes standalone with the same args; all 3 reported findings are
pre-existing (audit gate excludes 4 inherited). CI will run the
authoritative check.
The CI=true early-exit in shouldTrack() was hiding most modern usage
(coding agents in Codespaces, CI pipelines, agent sandboxes). Remove it.
Each event still carries is_ci/is_docker/is_tty from system.ts, so CI vs
laptop traffic can be separated in PostHog without being dropped at
ingestion.
HeyGen's own CI is suppressed via HYPERFRAMES_NO_TELEMETRY=1 added to
each workflow that exercises the CLI.
User-facing guide for the automated template-rendering pipeline now
shippable end-to-end after PRs 9.1-9.4:
- What a template is (composition + data-composition-variables)
- Declaring variables (syntax, types, defaults, getVariables())
- Local iteration loop (hyperframes render --variables / --variables-file
/ --strict-variables)
- Deploying to Lambda (pointers to deploy guide + sites create)
- Single personalised render (lambda render --variables)
- Batch pipeline (lambda render-batch --batch users.jsonl, with a worked
5-row example, manifest output, progress polling, --dry-run)
- Programmatic via SDK (TypeScript example with deploySite +
Promise.all(renderToLambda))
- Working with large variables (the 256 KiB Step Functions ceiling,
URL-your-assets convention, the one-line escape note for genuine
>256 KiB cases)
- Cost + scale considerations (Lambda concurrency, max-parallel-chunks
vs max-concurrent, in-process vs distributed crossover)
- Migrating from @remotion/lambda inputProps (side-by-side table; same
256 KiB cap and same URL-your-assets convention, so migration is
mechanical)
Includes a Mermaid architecture diagram for the site-upload-once +
N-execution fan-out flow at the top.
Adds the guide to the Deploy navigation group in docs.json (between
the existing aws-lambda and migrating-to-hyperframes-lambda pages).
Phase 9 PR 9.5 of the distributed rendering plan — the load-bearing
artifact for the user-facing pitch.
New subcommand for automated template-rendering pipelines. Given a
project dir + a JSONL batch file, fans out N personalised renders by
calling renderToLambda once per batch row with per-entry variables and
outputKey:
hyperframes lambda render-batch ./my-template \
--batch ./users.jsonl \
--width 1920 --height 1080 \
--max-concurrent 10
JSONL format (one JSON object per line):
{"outputKey": "renders/alice.mp4", "variables": {"name": "Alice"}}
{"outputKey": "renders/bob.mp4", "variables": {"name": "Bob"}}
The verb deploys the site once and reuses it across renders (--site-id
skips the deploy when the project was pre-uploaded). Concurrent Step
Functions starts are capped at --max-concurrent (default 50) via a
semaphore so a 10 000-entry batch doesn't try to spawn 10 000
executions simultaneously and trip the AWS account's concurrent-
execution quota.
Per-entry results land in a manifest (one row per input line) with
executionArn + status. --json emits the manifest as machine-readable
JSON. --dry-run prints the manifest with status: "would-invoke" for
every entry without calling AWS, so callers can lint their batch file
before paying for N executions.
Variables in each batch entry pre-validate against the composition's
data-composition-variables declaration (mirroring the local
hyperframes render UX). --strict-variables aborts the run on the first
failing entry before any AWS call. The reportVariableIssues helper from
PR 9.3 is reused so the warning format matches the single-render path
exactly.
Distinction from --max-parallel-chunks: --max-concurrent caps
ORCHESTRATOR-side fan-out (how many StartExecution calls run at once);
--max-parallel-chunks caps chunks PER render. AWS account-level Lambda
concurrent-execution limits live one level up and render-batch can't
enforce those; pick --max-concurrent based on your account quota +
the reserved concurrency you provisioned via lambda deploy.
Tests cover the concurrency-cap semaphore (preserve-order,
peak-in-flight, empty-input, limit > inputs.length, propagate
rejection) and the JSONL parser (blank-line handling, malformed JSON,
missing outputKey, non-object variables).
Phase 9 PR 9.4 of the distributed rendering plan.
Mirror the local hyperframes render variables UX on the Lambda CLI:
- --variables '<json>' inline JSON object of variable values
- --variables-file <path> path to a JSON file with variable values
- --strict-variables fail on type/declared-mismatch (warn by default)
Resolution + validation logic is hoisted to packages/cli/src/utils/variables.ts
so both surfaces share one parser. The new reportVariableIssues helper formats
the warning block + handles --strict-variables exit, deduping the per-CLI
issue-handling block.
Variables flow into SerializableDistributedRenderConfig.variables and reach
every chunk worker via the path PR 9.1 + 9.2 wired up (plan() →
meta/encoder.json → renderChunk() → window.__hfVariables). Pre-validation
against the composition's data-composition-variables declaration runs only
when the project's index.html is on disk — --site-id pointing at a
pre-uploaded site that was packaged elsewhere skips the check, matching how
the local CLI treats unreadable index files.
The render.ts re-exports of parseVariablesArg / resolveVariablesArg /
validateVariablesAgainstProject are dropped; the matching tests move to
packages/cli/src/utils/variables.test.ts where the implementations now live.
Docs: docs/packages/cli.mdx adds a section on --variables / --variables-file /
--strict-variables for lambda render, including the 256 KiB Step Functions
execution-input cap and a pointer to the upcoming templates-on-lambda guide
(PR 9.5).
Phase 9 PR 9.3 of the distributed rendering plan.
Add client-side validation for the new config.variables field
(introduced in PR 9.1) and a 256 KiB cap on the full Step Functions
Standard execution input. Both checks throw a typed InvalidConfigError
BEFORE the SDK calls StartExecution — catching the obvious mistakes
locally instead of as a States.DataLimitExceeded 50 ms into the
execution.
validateVariablesPayload walks the variables tree and rejects:
- functions, Symbols, BigInts, non-finite numbers
- undefined leaves (silently dropped by JSON.stringify — would
surprise the caller when their value doesn't show up in the render)
- non-plain objects (Date, Map, class instances) — Date's toJSON does
round-trip as a string, but the composition gets a string, not a
Date, so explicit reject is clearer
validateStepFunctionsInputSize measures the actual UTF-8 byte length
of JSON.stringify(input) against the 256 KiB cap. We use Standard
workflows (per the plan §6.2 / §15.2) for execution-history
visibility, so the cap is 256 KiB (Express would be 32 KiB). The error
message names the actual byte count, the cap, and points at the
templates-on-lambda#working-with-large-variables section so users
know to URL-reference media assets instead of inlining them.
Both helpers are exported from @hyperframes/aws-lambda/sdk so adapters
that build custom Step Functions inputs (batch verbs, future Temporal
ports) can reuse the same gates.
Phase 9 PR 9.2 of the distributed rendering plan.
Add `variables?: Record<string, unknown>` to DistributedRenderConfig
(§4.4) and LockedRenderConfig (§4.3). plan() snapshots the value into
meta/encoder.json so every chunk worker re-injects the same set via
captureOptions.variables, mirroring the in-process renderer's path.
The variables fold into planHash automatically because canonical
encoder.json bytes feed the hash: two plans with different variables
produce different hashes (chunked output depends on the injected
values); two plans with the same variables produce identical hashes
because canonical-JSON sorts keys.
The regression harnesses (distributed-simulated, lambda-local) also
forward the input's variables to plan() / Step Functions event so
fixtures that declare `renderConfig.variables` produce the same pixels
across modes. Previously the field was on the harness input shape but
silently dropped at the call boundary.
Phase 9 PR 9.1 of the distributed rendering plan.
* docs(lambda): document webm support in distributed mode
PR 8.4 of the WebM distributed-rendering plan (v1.5 backlog #1; see
DISTRIBUTED-RENDERING-PLAN.md §7.2). User-facing docs catch up with the
shipped capability.
Updates docs/deploy/migrating-to-hyperframes-lambda.mdx:
- "Output format" row in the migration table now lists `webm` alongside
mp4 / mov / png-sequence with a note that webm uses libvpx-vp9 +
closed-GOP concat-copy. HDR mp4 remains the only refused format.
- "No webm distributed" caveat replaced with "webm uses closed-GOP VP9"
explainer covering the encoder args (`-g <chunkSize>`,
`-keyint_min <chunkSize>`, `-auto-alt-ref 0`, `-cpu-used 2`), why
alt-ref disable is load-bearing, and that the output preserves alpha
via yuva420p with Opus audio.
- Migration checklist no longer asks adopters to filter out webm
compositions; only HDR-dependent renders need to stay on the previous
framework.
aws-lambda.mdx doesn't currently call out webm as unsupported (only HDR
in the v1 surface list), so it gets no copy edits beyond the migration
guide.
The internal planning doc (DISTRIBUTED-RENDERING-PLAN.md §7.2, §8,
§12 — kept outside the repo) gets matching updates: format support
matrix flipped ✓, v1.5 backlog #1 marked shipped, HDR promoted to the
new top item, and the rev-12 → rev-13 status line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: address simplify-review findings on webm stack
Folds in cleanups identified by a multi-agent code-review pass over the
4-PR webm-distributed stack:
- plan.ts: `resolveEncoderTriple()` webm case now calls
`getEncoderPreset(quality, "webm")` for its preset string instead of
hardcoding "good". The hardcode was wrong for `quality: "draft"`
(`getEncoderPreset` returns "realtime" for that tier) — would have
silently overridden the draft → realtime mapping for distributed webm
renders.
- chunkEncoder.ts: trim the new VP9 closed-GOP comment block from ~18
lines of WHY narration down to the 6 lines that actually explain why
(alt-ref + cpu-used drift). Match the alpha branch's idempotent-push
comment to the same standard.
- chunkEncoder.test.ts: drop the duplicate WHY comment that restated
the implementation comment in plain words.
- webm-concat-copy.test.ts: rewrite the file-header docstring to
describe the contract being tested instead of the PR-8.1-gating
history; strip "PR 8.2 / Path A / Path B" references from error
messages (they belong in PR bodies, not in test output). Consolidate
the yuva420p alpha smoke into a single `it()` block (was a full
4-test describe with duplicated setup) — the yuv420p block already
covers the probe/decode/frame-count contract; the alpha smoke only
needs to prove the alpha args don't break concat-copy.
- plan.test.ts: drop the "PR 8.1 proved the contract" comment.
- webm-vp9 fixture: drop the aspirational "Other webm-with-audio
fixtures cover the mux path separately when added" sentence (no
other fixtures exist). Regenerated the baseline via
`docker:test:update webm-vp9` to reflect the updated comment.
- migrating-to-hyperframes-lambda.mdx: add a paragraph about
distributed webm's perf cost — ~10-25% larger files at constant CRF
due to forced keyframes, and slower per-chunk encode due to
`-cpu-used 2` being more conservative than the libvpx default.
All unit tests + the webm-vp9 distributed-simulated regression still
pass after these changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): accept --format=webm in `hyperframes lambda render`
The CLI's `lambda render` subcommand's FORMATS allowlist and the
`RenderArgs.format` type still narrowed to `mp4 | mov | png-sequence`,
so even though the producer + aws-lambda packages now support webm
end-to-end, the CLI surface rejected it with `--format must be mp4|mov|
png-sequence`. Add webm to both spots and update the --help description.
Surfaced during real-AWS deploy prep — the local lambda-local /
distributed-simulated tests didn't go through the CLI so the gap went
unnoticed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(producer): font cache writes to /tmp on Lambda (read-only \$HOME)
The deterministic Google Fonts cache was rooted at
`\$HOME/.cache/hyperframes/fonts`, which fails on AWS Lambda — the
runtime's `\$HOME` resolves to a `/home/sbx_*` directory tree that's
read-only. `mkdirSync(..., { recursive: true })` can't create that
path and the plan stage trips with `ENOENT: no such file or directory,
mkdir '/home/sbx_user1051/.cache/hyperframes/fonts/space-mono'` on
every Lambda render that pulls a Google Font (i.e. every distributed
fixture using `@import url("https://fonts.googleapis.com/...")`).
Detect Lambda via `\$AWS_LAMBDA_FUNCTION_NAME` and route the cache to
`tmpdir()/hyperframes/fonts` in that case. Lambda's `/tmp` survives
across invocations on a warm container, so cache hit rate is the same
as non-Lambda runs. Also honor an explicit
`\$HYPERFRAMES_FONT_CACHE_DIR` override for adopters who want a
different location regardless of the runtime.
Surfaced while verifying webm distributed end-to-end on real AWS — the
same bug affects mp4 fixtures using Google Fonts; webm just happened to
be the one I tried first.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: extract DistributedFormat type + trim font-cache resolver
Second simplify-review pass on the webm stack flagged two cleanups:
1. **`DistributedFormat` type duplicated 10 times.** Every file in the
distributed pipeline carried its own copy of
`"mp4" | "mov" | "png-sequence" | "webm"` — adding a new format
meant a 10-place edit with no compile-time guarantee they stayed in
sync. Extract a single source of truth in
`packages/producer/src/services/distributed/shared.ts`, re-export
from `@hyperframes/producer/distributed` and
`@hyperframes/aws-lambda/sdk`, and have all callers pull from
there. The aws-lambda `ALLOWED_FORMATS` runtime tuple and the CLI's
`FORMATS` tuple now both use `satisfies readonly DistributedFormat[]`
so the compiler enforces the runtime allowlist stays in sync with
the type.
2. **`deterministicFonts.ts` font-cache resolver was over-commented.**
Trim the 7-line block to 4 lines (drop the aspirational
"and other read-only-FS execution environments" — only Lambda is
detected — and the warm-container `/tmp` persistence narration —
anyone reading already knows Lambda /tmp semantics). Collapse the
two-step `if (explicit && explicit.length > 0)` into a single
nullish-coalesce expression now that the empty-string defensive
check is gone (`process.env.X` is `string | undefined`, no third
shape to guard against).
Out-of-scope skips (called out by the agents, deferred):
- In-process `RenderConfig.format` and the in-process CLI's
`render.ts` format union still carry their own inline copies. The
union happens to coincide today but they're separate concerns —
leaving them alone limits this PR's blast radius.
- `fontCacheDir(slug)` / `resolveFontCacheRoot()` naming asymmetry
flagged as taste; skipping.
- Pre-existing redundant `existsSync` before `mkdirSync({ recursive:
true })` in `fontCacheDir` — out of scope.
All tests + typecheck still pass. Lambda render still works
end-to-end (no functional changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(lambda): drop plan-doc reference from migration checklist
PR review feedback: source/docs should not mention the
distributed-rendering planning doc. Tighten the migration checklist
sentence to describe the webm path directly rather than referencing
the doc's version label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(producer): split resolveEncoderTriple into mp4 + non-mp4 helpers
CI Fallow audit on PR #953 flagged `resolveEncoderTriple` at CRAP 31.6 —
the function interleaved (a) mp4 codec validation + dispatch, (b) the
non-mp4 codec-rejection throw, and (c) per-format dispatch. Splitting
into `resolveMp4EncoderTriple` + `resolveNonMp4EncoderTriple` drops the
top-level function's cyclomatic complexity below the threshold while
preserving every error message and code path. Behavior unchanged.
Also extracts an `EncoderTriple` type alias so the three functions
share the return shape declaratively rather than repeating it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(producer): enable webm in distributed mode via concat-copy
PR 8.2 of the WebM distributed-rendering plan (v1.5 backlog #1; see
DISTRIBUTED-RENDERING-PLAN.md §7.2). Wires libvpx-vp9 webm through the
distributed pipeline now that PR 8.1 proved concat-copy works.
Architectural decision: Path A (concat-copy) — based on PR 8.1's smoke
test result (9/9 tests pass for both yuv420p and yuva420p VP9 streams).
The simpler architecture wins; no re-encode in assemble, no encode-
parallelism loss.
Changes:
- plan.ts:
- DistributedRenderConfig.format and PlanResult.format now include
"webm" — type-level acceptance matches the runtime gate.
- rejectUnsupportedDistributedFormat() no longer trips on webm. HDR
mp4 remains the only refused configuration.
- resolveEncoderTriple() returns libvpx-vp9-software + yuva420p +
preset="good" for format="webm". yuva420p preserves alpha — the
format's main reason for existing for web delivery.
- codec= remains rejected for non-mp4 formats (mov is always ProRes
4444; webm is always libvpx-vp9). The error message lists all four
distributed-supported formats.
- FormatNotSupportedInDistributedError docstring updated to reflect
the new reality (only HDR is unsupported).
- freezePlan.ts: LockedRenderConfig.encoder gains "libvpx-vp9-software".
Mirrors libx265-software / prores-software / png-sequence in shape;
the chunk worker reads this discriminant to decide encode args.
- renderChunk.ts: drops the now-incorrect cast that excluded webm from
buildSyntheticRenderJob's format input; tightens the preset-format
cast to include webm.
- assemble.ts: docstring + comment updates. The mp4/mov concat-copy
path is format-agnostic — webm uses the exact same code (applyFaststart
is a no-op for webm via the existing chunkEncoder.ts gate;
muxVideoWithAudio already routes webm to libopus audio).
- planFormatBanlist.test.ts: webm-rejection tests removed; replaced with
"accepts webm" tests + a HDR+webm combo test that verifies HDR is the
trip regardless of format.
- plan.test.ts: new describe block pins the webm wiring contract:
format="webm" produces an encoder=libvpx-vp9-software /
pixelFormat=yuva420p planDir with closedGop=true and gopSize=chunkSize.
- webm-concat-copy.test.ts (smoke): extended with a yuva420p variant
that proves the alpha pixel format the distributed pipeline actually
emits also round-trips through concat-copy. 9/9 tests pass locally.
§8 format support matrix in DISTRIBUTED-RENDERING-PLAN.md is intentionally
left unchanged at this PR — it flips to ✓ in PR 8.4 once the end-to-end
fixture (PR 8.3) is green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(producer): include webm in plan-time needsAlpha + strengthen alpha smoke
PR review feedback from Miguel and Vai on #951 caught a real bug:
`plan.ts`'s `needsAlpha` disjunction excluded `"webm"`, so the plan
stage froze `forceScreenshot: false` into the `LockedRenderConfig`
even though distributed webm uses `yuva420p`. Every chunk worker
captured opaque RGB via BeginFrame (which doesn't preserve alpha on
Linux headless-shell), and libvpx-vp9 encoded uniformly-opaque alpha
that the encoder then dropped — producing un-keyable webm.
Two changes:
1. **plan.ts**: include `"webm"` in `needsAlpha`. Matches the
in-process renderer's logic at `renderOrchestrator.ts:1469`
(`const needsAlpha = isWebm || isMov || isPngSequence`); the two
sites must stay in sync since the distributed pipeline's PSNR
regression compares against the in-process baseline.
2. **Smoke test (yuva420p describe)**: source frames now use a real
alpha gradient (`geq=a='X*255/W'` on top of `testsrc2`) instead of
`testsrc2 + format=rgba` which was uniformly opaque. The decode-
pix_fmt assertion is dropped (ffprobe reports `yuv420p` for
VP9-with-alpha because the alpha lives in a Matroska
`BlockAdditional` sidecar) and replaced with two stronger checks:
- `TAG:ALPHA_MODE=1` is present on the stream — proves the
encoder was actually configured for alpha
- alpha plane variance after `-c:v libvpx-vp9 -i ... -pix_fmt rgba
-vf extractplanes=a,signalstats` — proves the alpha sub-stream
round-trips through concat-copy with spatially-varying content,
not uniform/dropped alpha
- decode-test gate is now exit-code-only (was `exitCode || stderr`
which would flake on chatty ffmpeg `-v error` builds emitting
non-fatal DTS/container notes)
These checks would have caught the `needsAlpha` bug before review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(aws-lambda): widen narrow format types to include webm
CI on PR #951 was failing at typecheck/build because the producer's
`DistributedRenderConfig.format` widened to include webm in this PR
but the aws-lambda package's narrow `"mp4" | "mov" | "png-sequence"`
type literals in `events.ts`, `handler.ts`, and `validateConfig.ts`
hadn't kept up. `renderToLambda.ts:87` passed `config.format` (now
including webm) into a parameter typed against the narrow union,
producing TS2345.
This widening originally landed in PR #952 (test fixture PR) but
needs to be atomic with the producer's widening here to keep each
PR independently typecheck-clean.
Also refactor `formatExtension` from a switch dispatch to a
`Record<DistributedFormat, string>` lookup. Adding the webm case
tipped the switch's CRAP to the 30.0 fallow threshold; the lookup
table drops cyclomatic from 5 to 1 with the same compile-time
exhaustiveness guarantee (TS errors on missing entries when
`DistributedFormat` adds a new format). The runtime
`_exhaustive: never` throw was only protecting against a string
slipping past TS; `validateConfig.ts`'s `ALLOWED_FORMATS` already
gates untrusted input at the SDK boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(producer): add webm-vp9 distributed regression fixture
PR 8.3 of the WebM distributed-rendering plan (v1.5 backlog #1; see
DISTRIBUTED-RENDERING-PLAN.md §7.2). End-to-end regression coverage for
the webm distributed path PRs 8.1 and 8.2 wired up.
Adds packages/producer/tests/distributed/webm-vp9/ matching the
mp4-h264-sdr fixture pattern: a 2-second composition (60 frames @ 30fps)
with text, a crossfade across the frame-30 chunk seam, and a continuous
icon rotation — exercises chunk-boundary continuity for both display
contents and VP9 closed-GOP alpha encoding. `chunkSize: 15` produces 4
chunks so 3 seams are tested, and the crossfade straddles the middle
seam to surface alpha-plane discontinuities introduced by alt-ref drift.
Baseline regenerated inside Dockerfile.test via
`bun run --cwd packages/producer docker:test:update webm-vp9`. Runs in:
- in-process mode: byte-identical match against baseline ✓
- distributed-simulated mode: PSNR 56.88-63.49 dB across 100
checkpoints, well above the 30 dB threshold ✓
Wiring updates required to let webm flow through the harness:
- regression-harness-distributed.ts:
- checkDistributedSupport() no longer rejects webm. HDR mp4 + NTSC
fps + non-{24,30,60} fps remain rejected.
- RunDistributedSimulatedInput.format widened to include webm.
- Docstring + comments updated.
- regression-harness-distributed.test.ts: webm-rejection test replaced
with "accepts format=webm" test.
- regression-harness.ts: the now-incorrect format cast at the
distributed-input call site is dropped; comment about why webm was
excluded is replaced with "webm is now distributed-supported".
- regression-harness-lambda-local-types.ts: RunLambdaLocalInput.format
widened to include webm so lambda-local mode can also exercise webm
fixtures end-to-end.
- aws-lambda webm support (Path A through the Lambda handler):
- formatExtension.ts: DistributedFormat gains "webm" → ".webm" case.
- events.ts: RenderChunkEvent / AssembleEvent / PlanLambdaResult
Format widened to include webm.
- sdk/validateConfig.ts: ALLOWED_FORMATS gains "webm".
- handler.ts: downloadChunkObjects format param widened.
The Lambda handler delegates to the producer's assemble() primitive
which PR 8.2 already taught to handle webm (concat-copy + applyFaststart
no-op + muxVideoWithAudio with libopus); no Lambda-side rendering
changes are needed beyond the type/validation surfaces above.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(aws-lambda): drop stale webm rejection from validateConfig docblock
PR #952 review nit (Miguel): the validateConfig.ts file-header comment
still claimed the SDK rejects webm, but the runtime check no longer
does (ALLOWED_FORMATS now includes 'webm'). Update the docblock to
reflect that only force-hdr remains an SDK-side rejection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(regression): add webm-vp9 to shard-3 + refactor formatExtension
Three follow-ups bundled together (Vai's review feedback on PR #952
plus the fallow audit finding that surfaced when the webm case was
added):
1. **Wire webm-vp9 into CI regression.** The fixture was added in this
PR but never appeared in any `.github/workflows/regression.yml`
shard's args allowlist, so the regression harness's positional-args
gate skipped it in CI. Append `webm-vp9` to shard-3 (which already
carries `mp4-h264-sdr` + `webm-transparency`) so the fixture runs.
2. **Fix stale "four hard gates" prose in checkDistributedSupport
docstring.** Earlier in the stack I removed the webm bullet but
didn't update the count. Two gates remain (fps + hdr).
3. **Refactor `formatExtension` from switch to lookup table.** Adding
the webm case made the switch dispatch's CRAP score hit 30.0
(cyclomatic = 5, plus the function's small body). Replaced with a
`Record<DistributedFormat, string>` lookup, which:
- drops cyclomatic from 5 → 1,
- keeps exhaustiveness enforcement at compile time (TS errors if
a new format gets added to `DistributedFormat` without a
matching key in the Record literal),
- drops the runtime `_exhaustive: never` throw, which was only
guarding against an arbitrary string slipping past TS — a
caller-side concern, not this function's job.
The function now reads as a table lookup, which matches what it
actually does, and the fallow audit now reports zero new
complexity findings (down from 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After c8e8fdcf added a Google Fonts supplement-fetch to the Path 1
(bundled-font) branch, every `plan()` call against a composition whose
CSS named a non-Google family that Google Fonts 400s on (e.g.
`"Segoe UI"`, `"Arial"`, `"Futura"`) started failing on distributed
renders with `FONT_FETCH_FAILED`. Distributed renders default to
`failClosedFontFetch: true`, and the existing code treated *all* non-2xx
responses uniformly: throw if failClosed, swallow otherwise.
That conflates two very different failure modes:
- **4xx** is a *deterministic* answer — Google Fonts does not serve
this family, and won't serve it on retry either. The byte-identical-
retry contract distributed renders rely on is unaffected; the render
falls back to embedded faces / the composition's font-family chain
(which is what it would have done pre-c8e8fdcf anyway). No reason
to fail-close here.
- **5xx** (and network / DNS / fetch exceptions) is *non-deterministic*
infrastructure failure. A retry might succeed and produce different
pixel output than the first attempt — exactly what
`failClosedFontFetch` is meant to protect against. Keep failing
closed in this mode.
Fix: split the !res.ok branch in both the CSS fetch and the woff2 fetch
inside `fetchGoogleFont` — only `>= 500` paired with failClosed throws;
4xx returns `[]` in both modes. Network/DNS exceptions in the catch
block are unchanged (still failClosed-gated).
This:
- Unblocks distributed renders for compositions that name any
cross-alias system font Google doesn't serve (Segoe UI, Arial, etc.).
- **Preserves the current regression baseline** — Google Fonts
actually *does* serve some non-canonical names (e.g. "Helvetica" and
"Helvetica Neue" both return HTTP 200 with real @font-face rules,
confirmed via curl), so the supplement-fetch still runs and binds
those real faces to the composition's CSS family names exactly as
today. style-7-prod (which uses `"Helvetica Neue", Helvetica, Arial,
sans-serif`) continues to render against real Helvetica glyphs.
- Leaves the FONT_ALIASES table and call-site untouched. The fix is
in the right place — the error semantics inside fetchGoogleFont —
not in any composition-aware logic upstream.
Tests:
- 2 new positive cases on `failClosedFontFetch: true`:
400 and 404 responses no longer throw, render falls back cleanly.
- 2 new negative cases on `failClosedFontFetch: true`:
503 throws `FONT_FETCH_FAILED`, error includes URL + family.
- 1 new case on `failClosedFontFetch: false`: 5xx swallowed as before.
- The pre-existing "does NOT throw when the HTML uses a pre-bundled
font" test was broken by c8e8fdcf (the supplement-fetch always fires
for self-aliased bundled fonts now). Updated it to use a successful
empty CSS response, which is the actual invariant we want.
12/12 tests pass.
Followup discussion: should `FONT_ALIASES` exist at all in a
deterministic cloud renderer? Today `font-family: "Helvetica"` in CSS
silently produces real Helvetica glyphs (via Google Fonts' undocumented
alias serving) and `font-family: "Segoe UI"` silently produces embedded
Roboto, with no warning to the author. That's a WYSIWYG violation worth
its own proposal — but not in scope for this fix.
* feat(producer): enable webm in distributed mode via concat-copy
PR 8.2 of the WebM distributed-rendering plan (v1.5 backlog #1; see
DISTRIBUTED-RENDERING-PLAN.md §7.2). Wires libvpx-vp9 webm through the
distributed pipeline now that PR 8.1 proved concat-copy works.
Architectural decision: Path A (concat-copy) — based on PR 8.1's smoke
test result (9/9 tests pass for both yuv420p and yuva420p VP9 streams).
The simpler architecture wins; no re-encode in assemble, no encode-
parallelism loss.
Changes:
- plan.ts:
- DistributedRenderConfig.format and PlanResult.format now include
"webm" — type-level acceptance matches the runtime gate.
- rejectUnsupportedDistributedFormat() no longer trips on webm. HDR
mp4 remains the only refused configuration.
- resolveEncoderTriple() returns libvpx-vp9-software + yuva420p +
preset="good" for format="webm". yuva420p preserves alpha — the
format's main reason for existing for web delivery.
- codec= remains rejected for non-mp4 formats (mov is always ProRes
4444; webm is always libvpx-vp9). The error message lists all four
distributed-supported formats.
- FormatNotSupportedInDistributedError docstring updated to reflect
the new reality (only HDR is unsupported).
- freezePlan.ts: LockedRenderConfig.encoder gains "libvpx-vp9-software".
Mirrors libx265-software / prores-software / png-sequence in shape;
the chunk worker reads this discriminant to decide encode args.
- renderChunk.ts: drops the now-incorrect cast that excluded webm from
buildSyntheticRenderJob's format input; tightens the preset-format
cast to include webm.
- assemble.ts: docstring + comment updates. The mp4/mov concat-copy
path is format-agnostic — webm uses the exact same code (applyFaststart
is a no-op for webm via the existing chunkEncoder.ts gate;
muxVideoWithAudio already routes webm to libopus audio).
- planFormatBanlist.test.ts: webm-rejection tests removed; replaced with
"accepts webm" tests + a HDR+webm combo test that verifies HDR is the
trip regardless of format.
- plan.test.ts: new describe block pins the webm wiring contract:
format="webm" produces an encoder=libvpx-vp9-software /
pixelFormat=yuva420p planDir with closedGop=true and gopSize=chunkSize.
- webm-concat-copy.test.ts (smoke): extended with a yuva420p variant
that proves the alpha pixel format the distributed pipeline actually
emits also round-trips through concat-copy. 9/9 tests pass locally.
§8 format support matrix in DISTRIBUTED-RENDERING-PLAN.md is intentionally
left unchanged at this PR — it flips to ✓ in PR 8.4 once the end-to-end
fixture (PR 8.3) is green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(producer): include webm in plan-time needsAlpha + strengthen alpha smoke
PR review feedback from Miguel and Vai on #951 caught a real bug:
`plan.ts`'s `needsAlpha` disjunction excluded `"webm"`, so the plan
stage froze `forceScreenshot: false` into the `LockedRenderConfig`
even though distributed webm uses `yuva420p`. Every chunk worker
captured opaque RGB via BeginFrame (which doesn't preserve alpha on
Linux headless-shell), and libvpx-vp9 encoded uniformly-opaque alpha
that the encoder then dropped — producing un-keyable webm.
Two changes:
1. **plan.ts**: include `"webm"` in `needsAlpha`. Matches the
in-process renderer's logic at `renderOrchestrator.ts:1469`
(`const needsAlpha = isWebm || isMov || isPngSequence`); the two
sites must stay in sync since the distributed pipeline's PSNR
regression compares against the in-process baseline.
2. **Smoke test (yuva420p describe)**: source frames now use a real
alpha gradient (`geq=a='X*255/W'` on top of `testsrc2`) instead of
`testsrc2 + format=rgba` which was uniformly opaque. The decode-
pix_fmt assertion is dropped (ffprobe reports `yuv420p` for
VP9-with-alpha because the alpha lives in a Matroska
`BlockAdditional` sidecar) and replaced with two stronger checks:
- `TAG:ALPHA_MODE=1` is present on the stream — proves the
encoder was actually configured for alpha
- alpha plane variance after `-c:v libvpx-vp9 -i ... -pix_fmt rgba
-vf extractplanes=a,signalstats` — proves the alpha sub-stream
round-trips through concat-copy with spatially-varying content,
not uniform/dropped alpha
- decode-test gate is now exit-code-only (was `exitCode || stderr`
which would flake on chatty ffmpeg `-v error` builds emitting
non-fatal DTS/container notes)
These checks would have caught the `needsAlpha` bug before review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(aws-lambda): widen narrow format types to include webm
CI on PR #951 was failing at typecheck/build because the producer's
`DistributedRenderConfig.format` widened to include webm in this PR
but the aws-lambda package's narrow `"mp4" | "mov" | "png-sequence"`
type literals in `events.ts`, `handler.ts`, and `validateConfig.ts`
hadn't kept up. `renderToLambda.ts:87` passed `config.format` (now
including webm) into a parameter typed against the narrow union,
producing TS2345.
This widening originally landed in PR #952 (test fixture PR) but
needs to be atomic with the producer's widening here to keep each
PR independently typecheck-clean.
Also refactor `formatExtension` from a switch dispatch to a
`Record<DistributedFormat, string>` lookup. Adding the webm case
tipped the switch's CRAP to the 30.0 fallow threshold; the lookup
table drops cyclomatic from 5 to 1 with the same compile-time
exhaustiveness guarantee (TS errors on missing entries when
`DistributedFormat` adds a new format). The runtime
`_exhaustive: never` throw was only protecting against a string
slipping past TS; `validateConfig.ts`'s `ALLOWED_FORMATS` already
gates untrusted input at the SDK boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Description
PR 1 of 4 in the WebM (VP9) distributed-rendering series. A gating
experiment that proves closed-GOP libvpx-vp9 chunks survive
`ffmpeg -f concat -c copy` losslessly, so the rest of the stack can
ship Path A (concat-copy) rather than the slower
re-encode-in-assemble fallback.
Two changes:
1. **Closed-GOP VP9 encoder args.** `buildEncoderArgs` now lays
`-g <chunkSize>`, `-keyint_min <chunkSize>`, `-auto-alt-ref 0`, and
`-cpu-used 2` on libvpx-vp9 when `lockGopForChunkConcat=true`.
Mirrors the existing libx264/libx265 branches. The alt-ref disable
is load-bearing — libvpx-vp9's default non-displayable alt-ref
frames can reach across chunk seams and break concat-copy.
`-cpu-used 2` pins the speed/quality tradeoff so chunks encoded on
workers with different libvpx-vp9 defaults produce visually
consistent output across seams. Default (`lockGopForChunkConcat`
unset) preserves the existing in-process VP9 path unchanged.
2. **Concat-copy smoke test** at
`packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts`.
Generates 60 PNGs via lavfi `testsrc2`, encodes them as 4 VP9 chunks
of 15 frames using `buildEncoderArgs` with
`lockGopForChunkConcat=true`, concat-copies via `ffmpeg -f concat -c
copy`, then runs three independent verifications:
`ffprobe -show_streams`, `ffmpeg -f null -` decode test, and
`ffprobe -count_frames`. Each verification surfaces its failure
fingerprint in the error message.
Smoke test passes 6/6 locally → Path A works; the rest of the stack
takes it.
Also exports `buildEncoderArgs` from `@hyperframes/engine` so
adapters / tests can construct args without re-implementing the
contract.
## Testing
- [x] `bunx vitest run --root packages/engine src/services/chunkEncoder.test.ts` — 62/62 pass (new VP9 closed-GOP tests included)
- [x] `bun test packages/producer/tests/distributed/_smoke/webm-concat-copy.test.ts` — passes
- [x] `bunx oxlint` + `bunx oxfmt --check` on all changed files — clean
- [x] `bunx tsc --noEmit -p packages/engine/tsconfig.json` — clean
🤖 Generated with [Claude Code](https://claude.com/claude-code)
* ci: run fallow audit in lefthook pre-commit
Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.
Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.
Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.
The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.
* refactor: delete orphan declarations flagged by fallow
After fallow's auto-fix de-exports unused symbols, oxlint surfaces them
as no-unused-vars. This PR deletes those orphan declarations outright.
Biggest cleanup: studio/src/icons/SystemIcons.tsx shrinks from 132 to 57
lines — 33 unused icon wrappers and their phosphor-icon imports deleted.
Other deletions across 14 more files covering paired getter/setters,
helper functions, dead env constants, internal components with no
callers, and cascading unused imports.
Cascade-causing files held back for follow-up PRs: renderOrchestrator
barrel of captureCost re-exports, telemetry/portUtils/remote barrels,
Button.tsx + ui/index.ts (would orphan whole file), studioMotion
type re-exports.
Test plan: typecheck clean across 8 packages, oxlint + oxfmt clean,
fallow audit exit 0 (remaining findings inherited), cli + studio
vitest suites pass.
* ci: post sticky PR comment with fallow audit findings
Reviewers shouldn't have to dig through CI logs to see what fallow
flagged. With this change, on every PR the fallow job posts (or
updates) a sticky comment containing the full audit report formatted
as a collapsible markdown table.
The comment uses fallow's built-in `pr-comment-github` format, which
already emits a `<!-- fallow-id: fallow-results -->` sentinel.
`marocchino/sticky-pull-request-comment@v2.9.1` matches that header so
each run replaces the previous comment instead of stacking new ones.
The job now runs in three steps:
1. Run `fallow audit ... --format pr-comment-github` with
`continue-on-error: true` so the comment posts even when the audit
fails. Exit code is captured.
2. Post (or update) the sticky comment with the captured output.
3. Re-emit the audit exit code so the job still fails-the-build on
new findings.
Bumps the workflow's `pull-requests` permission from read to write,
needed for the sticky-comment poster to call the issues API.
* ci: run fallow audit in lefthook pre-commit
Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.
Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.
Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.
The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.
* refactor: delete orphan declarations flagged by fallow
After fallow's auto-fix de-exports unused symbols, oxlint surfaces them
as no-unused-vars. This PR deletes those orphan declarations outright.
Biggest cleanup: studio/src/icons/SystemIcons.tsx shrinks from 132 to 57
lines — 33 unused icon wrappers and their phosphor-icon imports deleted.
Other deletions across 14 more files covering paired getter/setters,
helper functions, dead env constants, internal components with no
callers, and cascading unused imports.
Cascade-causing files held back for follow-up PRs: renderOrchestrator
barrel of captureCost re-exports, telemetry/portUtils/remote barrels,
Button.tsx + ui/index.ts (would orphan whole file), studioMotion
type re-exports.
Test plan: typecheck clean across 8 packages, oxlint + oxfmt clean,
fallow audit exit 0 (remaining findings inherited), cli + studio
vitest suites pass.
Mirrors the same `fallow audit --base ... --fail-on-issues` check that
runs in CI, but locally against HEAD so issues surface at commit time
instead of after the push round-trip.
Scoped to `packages/**` source files via the glob — non-code edits
(README, docs, top-level configs) skip the hook entirely.
Measured locally: ~5s in parallel with the existing lint/format/typecheck
checks. Doesn't extend wall-clock time because typecheck (~11s) is the
long pole, and lefthook runs commands in parallel.
The default `--gate new-only` means inherited findings don't block the
commit — same gate behavior as CI, so local pre-commit and PR audit
agree.
After #916 moved `assertSwiftShader` from `renderChunk()`'s eager probe
session into `executeWorkerTask`, every parallel worker began running its
own `chrome://gpu` / canvas-WebGL probe. At `chunkWorkerCount=6` (texture
launch at chunks=3) that's 6 concurrent CDP page-loads per chunk × 3
chunks = 18 simultaneous probes. Bench data on dev (12 producer pods × 22
vCPU) showed c=3 worst-case wall-clock at 67.3s, 24.7s above c=6 worst
(42.6s) — pod_total inflates 100s → 147s uniformly across all three
chunks per slow iter, the signature of cluster-level CDP contention
rather than within-pod contention.
Workers within a chunk share the same Chrome binary, flags, and OS/driver
state on a single pod, so worker 0's success is representative for the
rest. Gate the probe via `shouldVerifyWorkerGpu(workerId, config)` so
only worker 0 navigates to the probe page; workers 1..N-1 skip it. The
fail-fast contract still holds at the chunk level (worker 0 still aborts
the chunk if SwiftShader didn't load) — just without the concurrent CDP
traffic.
Expected wall-clock impact: c=3 worst drops from ~67s to in line with
c=6 worst (~42-44s). c=6 (3 workers/pod) and c=8 (2 workers/pod) should
see smaller wins; c=12 (1 worker/pod, sequential branch) is unaffected.
Closes#955.
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols
fallow's reachability analysis identifies as unused. Keeps only the cases
where the symbol is still referenced internally in its own file (so
removing `export` doesn't surface a new oxlint `no-unused-vars` error).
Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused
exports), with no behavior change — each symbol is still defined and used
exactly the same way within its file.
Reverted ~20 files where fallow's auto-fix would have created cascading
"declared but never used" lint errors — those are cases where the symbol
isn't used at all, and properly cleaning them up means deleting the
declaration, not just dropping `export`. Better to land that as a
separate, narrower PR rather than mixing it into a mechanical de-export.
Also reverted four false positives where fallow missed real consumers:
- `captureCost.ts` (renderOrchestrator has two separate import blocks
from the same module; fallow only saw the first)
- `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses
fallow's reachability missed)
- `render.ts` (functions imported via `await import()` dynamic import,
which fallow's static analysis doesn't follow)
Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean,
cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests).
Address PR review feedback on #939:
- Pin chunkSize=240 on the golden planDir layout test so the 1-chunk path
through plan() stays exercised after the auto-sizer change. Assert
chunkCount === 1 explicitly (previously just >= 1).
- Add an integration test that runs plan() with chunkSize=undefined and
asserts the auto-sizer produces multi-chunk output end-to-end
(chunkCount=3, encoder.gopSize=10, encoder.chunkSize=10) for the same
30-frame fixture.
- Document the GOP/file-size trade-off on the chunkSize docstring so
adopters who optimize for output bytes know to pin chunkSize.
- Update the resolveChunkPlan docstring formula to reference the operative
variable (resolvedChunkSize) instead of the now-ambiguous chunkSize.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address self-review findings:
- assertPositiveInteger now only runs on the caller-supplied path so the
error message names `configChunkSize` only when the caller actually
passed one. Previously, the assertion fired against `resolvedChunkSize`
on both paths and would have lied about the offending input.
- Drop the call-site comment that narrated the diff/history; the
function docstring already covers the contract.
- Drop the internal-track name and date from the MIN_CHUNK_SIZE rationale
and the test block header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, plan() defaulted chunkSize to 240 on a `?? DEFAULT_CHUNK_SIZE`
line, so a 660-frame composition with maxParallelChunks=16 ended up at 3
chunks (ceil(660/240)) regardless of the caller's fan-out intent.
When config.chunkSize is undefined, auto-size from maxParallelChunks:
effectiveChunkSize = max(MIN_CHUNK_SIZE, ceil(totalFrames / maxParallelChunks))
MIN_CHUNK_SIZE=10 keeps per-chunk fixed overhead from swamping the
parallelism gain on tiny renders. Explicit numbers, including 240, take
precedence over the auto-sizer — no behavior change for callers that set
chunkSize explicitly.
Surfaced by the lever-1 chunk-scaling benchmark on 2026-05-17.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Configure fallow via .fallowrc.jsonc so its analysis reflects this repo's
real entry surface, then fix the genuine issues it found.
Fallow noise reduction (601 → 276 dead-code findings):
- Ignore docs/, test fixtures, skill test-corpora, registry/, examples/
- Declare worker entry points loaded dynamically by file path
(pngDecodeBlitWorker.ts, shaderTransitionWorker.ts)
- Declare runtime IIFE entry (core/src/runtime/entry.ts) built outside the
import graph by build-hyperframes-runtime-artifact.ts
- Declare bun:test files in producer + aws-lambda as test entries
- Ignore dynamically-resolved deps: tsup external (puppeteer-core, esbuild,
giget), peer/static-file (gsap in player perf tests), workspace deps
hoisted by bun (happy-dom, @hyperframes/*), and @fontsource/* packages
read via readFileSync in generate-font-data.ts
Extract inline build:fonts scripts:
- packages/{cli,producer}/package.json had multi-line `node -e ...` blobs
containing braces that fallow mis-parsed as glob alternate groups. Moved
to dedicated build-fonts.mjs scripts.
Fix duplicate exports:
- Remove dead FileIcon alias in studio/SystemIcons.tsx (FileTreeIcons.tsx
has the real, used one)
- Consolidate ValidationResult: drop the identical duplicate in
gsapParser.ts; both parsers now import from core.types
- Suppress intentional namespace patterns (per-namespace ML manager
exports; CLI per-command 'examples' convention; fileServer.ts test-only
isPathInside which has different symlink semantics from utils/paths.ts)
Break circular dep (studio/components/editor):
- manualEditsDom.ts re-exported clearStudioPathOffset / clearStudioRotation
/ clearStudioBoxSize from manualEditsSnapshot.ts, which imports four
helpers from manualEditsDom.ts — back-edge cycle
- Re-export moved to manualEdits.ts (the package-public barrel) where the
rest of the snapshot re-exports already live; underlying files now form
a clean DAG
Remove genuinely unused deps:
- studio: motion (no imports anywhere), codemirror (umbrella package; the
@codemirror/* sub-packages are used directly)
- cli: mime-types (plus its only consumer src/utils/mime.ts, which was a
hardcoded mime table that didn't use the package), and its now-stale
tsup external entry
Verified: typecheck across core/cli/producer/studio is clean, oxlint
+ oxfmt pass, manualEdits.test.ts (18 tests) and core parser tests (69
tests) still pass.
Deferred follow-ups (real findings, separate PRs):
- 8 circular deps in producer/services/render/stages/ — renderOrchestrator
↔ captureHdr* / captureStage / extractVideosStage form a hub cycle
- ~14 unused files in producer/src/services/ that look like dead
re-export shims to @hyperframes/engine, but aren't in the public
exports map — need to confirm no deep-import consumers before deletion
- waveform.ts complexity hotspot
The tsconfig `exclude` list isn't enough to keep producer's tsc emit pass
from pulling `regression-harness-lambda-local.ts` (and its
`@hyperframes/aws-lambda` static imports) into the program — tsc still
statically resolves the path in
`await import("./regression-harness-lambda-local.js")` from
`regression-harness.ts`, walks into the excluded file, and fails on
the missing aws-lambda type declarations.
Reproduction (clean workspace, no aws-lambda dist yet, mirrors CI):
rm -rf packages/{aws-lambda,producer,core}/dist
bun run build
# @hyperframes/producer build: src/regression-harness-lambda-local.ts(36,70):
# error TS2307: Cannot find module '@hyperframes/aws-lambda' or its
# corresponding type declarations.
Fix: route the dynamic import path through a top-level string constant
so tsc can't statically resolve the target. tsc keeps the type-only
imports (`RunLambdaLocalRender` from the no-aws-lambda types file) and
treats the dynamic-import target as opaque. `tsx` resolves the path
normally at runtime, so `--mode=lambda-local` is unchanged.
Two follow-ups to keep the new package in lockstep with the rest of the
@hyperframes/* release cadence from day one:
- Bump packages/aws-lambda/package.json version 0.6.18 → 0.6.20 so it
matches what main released while this PR was in review. Without this,
the package would land below the rest of the lockstep and the next
release-bump would jump aws-lambda from 0.6.18 → 0.6.21 in one step.
- Add packages/aws-lambda to PACKAGES in scripts/set-version.ts so the
next `chore: release vX.Y.Z` commit bumps aws-lambda alongside the
other publishable packages. Without this, set-version silently skips
aws-lambda — package.json stays frozen, pnpm publish would re-publish
the same version on every release, and the npm-view precheck in
publish.yml would skip-with-success and never actually push a new
version of the package.
Two narrow fixes to keep the regression suite green and reproducible.
Stale baselines from the sub-composition refactor (PR #918) are being
regenerated separately in PR #925; this PR is just the structural
fixes that PR can't make on its own.
1. **Pin `chrome-headless-shell` in `Dockerfile.test`** to
`148.0.7778.167` instead of `@stable`. `@stable` is a moving tag;
every Chrome stable promotion shifts pixel output enough to fail
PSNR on the golden baselines, so the regression suite silently
broke whenever Docker.test rebuilt against a freshly-promoted
stable. Pinning to the version `@stable` currently resolves to
(matching what main's regenerated baselines were captured under)
makes Chrome bumps an explicit, batched-with-baseline-regen
action. Comment on the `RUN` line spells out the bump procedure.
2. **Clamp the last PSNR checkpoint to a frame the video stream
actually contains.** `runTestSuite` samples 100 checkpoints across
`min(rendered, snapshot)` container duration. Container duration
includes audio padding past the last video frame — many-cuts is
5.654s container vs 5.6s of video at 30fps = 168 frames. At i=99
the raw container duration mapped to time 5.59746s → frame index
168 (round(5.59746 × 30)), one past the last frame the stream
contains. ffmpeg's `psnr` filter emits no `average:` line for a
non-existent frame, so the harness crashed with `Unable to parse
PSNR output at 5.59746s` — pre-existing on plain `origin/main`,
which PR #918 admin-merged through on shard-2. Miguel's regen via
`--update` didn't catch it because `--update` only writes the
snapshot; it doesn't validate. Subtracting one frame interval
from the sampling duration guarantees the last checkpoint always
lands on a real frame.
Verified locally inside `Dockerfile.test`:
bun run --cwd packages/producer docker:build:test
bun run --cwd packages/producer docker:test many-cuts # ✅ green
bun run --cwd packages/producer docker:test style-3-prod \
style-5-prod sub-composition-video # ✅ green
The aws-lambda publish-readiness changes earlier in this PR moved
aws-lambda's types from `./src/index.ts` → `./dist/index.d.ts`. That
flipped producer's emit pass from "always works" to "needs aws-lambda
built first," because producer's `regression-harness-lambda-local.ts`
imports `@hyperframes/aws-lambda{,/handler}`. aws-lambda's own emit
pass in turn imports `@hyperframes/producer{,/distributed}` → circular
build dependency, so neither side can emit declarations in a single
pass. CI's first run on this PR failed Build, Typecheck, CLI smoke,
windows tests, and every perf job for this reason.
Fix: extract the public surface of `regression-harness-lambda-local.ts`
into a new types-only file that has no aws-lambda imports, point
`regression-harness.ts` at that types file, and exclude
`regression-harness-lambda-local.ts` from producer's tsconfig
`include`. The implementation file is still loaded at runtime via
`tsx` (lambda-local mode runs the harness through tsx, not from
producer's dist/), so the runtime contract is unchanged — producer's
tsc just no longer has to type-check it.
- `regression-harness-lambda-local-types.ts` (new): exports
`RunLambdaLocalInput` + `RunLambdaLocalRender` with zero
aws-lambda imports.
- `regression-harness-lambda-local.ts`: re-exports
`RunLambdaLocalInput` from the new types file (so the public
name stays stable for any future direct imports).
- `regression-harness.ts`: drops the `typeof import("...")` trick
and uses the explicit `RunLambdaLocalRender` signature for the
dynamically-loaded function.
- `tsconfig.json`: excludes `src/regression-harness-lambda-local.ts`
so producer's emit pass never resolves `@hyperframes/aws-lambda`.
Verified:
bun run build # full root build green
bun run verify:packed-manifests # all packages publish-safe
The Lambda adapter has been on `main` since PR #909 but its package
manifest still shipped TypeScript source (`main: ./src/index.ts`,
`build: tsc --noEmit`, `version: 0.0.1`) and the publish workflow
didn't list it. This wires it up to publish alongside the other
`@hyperframes/*` packages on the next `v*` tag.
Changes:
- **packages/aws-lambda/build.mjs (new)** — mirrors
`packages/producer/build.mjs`: esbuild bundles four entry
points (`src/index.ts`, `src/handler.ts`, `src/sdk/index.ts`,
`src/cdk/index.ts`) → `dist/`, then `tsc --emitDeclarationOnly`
emits .d.ts via `tsconfig.build.json`. All runtime/peer deps
(@aws-sdk/*, @hyperframes/producer*, @sparticuz/chromium,
aws-cdk-lib, constructs, ffmpeg-static, ffprobe-static,
puppeteer-core, tar) are external so consumers resolve them
through their own node_modules.
- **packages/aws-lambda/tsconfig.build.json (new)** — drops the
workspace `paths` overrides so `@hyperframes/producer*`
resolves through node_modules to producer's already-built
`dist/` types instead of pulling its full source tree into
emit (which would violate `rootDir`).
- **packages/aws-lambda/tsconfig.json** — keeps `noEmit: true`
+ workspace `paths` for fast in-place typechecks; also
excludes `src/**/__fixtures__/**` so test-only helpers
(fakeS3) don't leak into emitted declarations.
- **packages/aws-lambda/package.json**:
* version bumped 0.0.1 → 0.6.18 (matches the repo's lockstep
release cadence)
* main / types / exports map points at `dist/...`
* files: ["dist/", "scripts/", "README.md"] (scripts/ kept
whole because build-zip.ts and verify-zip-size.ts both
import scripts/_formatBytes.ts)
* scripts.build = `node build.mjs`
- **package.json** — root `build` filter includes
`aws-lambda` so `bun run build` builds it in topological
order after producer.
- **.github/workflows/publish.yml** — one new
`publish_pkg "@hyperframes/aws-lambda" "@hyperframes/aws-lambda"`
line. First publish is automatic via the `--access public` flag
in `publish_pkg`; the @hyperframes scope already owns the name.
Verification:
bun run build # full root build green
bun run verify:packed-manifests # aws-lambda passes
pnpm pack packages/cli # @hyperframes/aws-lambda
# rewrites workspace:* → 0.6.18
npm install -g <cli-tgz> # smoke-install still works
hyperframes lambda deploy # friendly missing-package
# error still fires when
# aws-lambda isn't installed
Two adopter-facing artifacts that close out Phase 6b's user-facing
surface:
- docs/deploy/migrating-to-hyperframes-lambda.mdx — side-by-side
concept mapping for users coming from another one-command-deploy
video renderer. Covers the verb mapping (deploy/render/progress/
destroy/sites/policies), composition format (plain HTML vs JSX),
render config, and a handful of intentional differences (no HDR
in distributed mode, no webm, gpu-mode=software requirement,
fail-closed font fetch, local stack-state files, narrow-after-
first-deploy IAM pattern). Closes with a migration checklist.
Per repo convention, no competitor framework is named anywhere
in the source — adopters self-identify.
- examples/k8s-jobs/Dockerfile.example + README.md — reference
Dockerfile for adopters who want to run distributed renders
outside AWS Lambda. Bakes Node 22 + chrome-headless-shell +
ffmpeg + the producer source. Deliberately not published to
a registry; adopters build it themselves so Chrome / ffmpeg /
producer versions stay pinned to the checkout they audited.
The README documents the typical K8s Jobs orchestration shape
that points adopters at packages/aws-lambda/src/handler.ts as
the reference adapter.
Migration guide registered under the existing Deploy group in
docs.json. .gitignore extended to negate the new examples/k8s-jobs/
path the same way examples/aws-lambda/ is negated.
No source code changes.
* docs(lambda): add docs/deploy/aws-lambda.mdx deployment guide
End-to-end deploy guide for the AWS Lambda surface. Covers:
- Architecture diagram (Step Functions Plan → Map(N) → Assemble +
the single Lambda function dispatching by Action; pulled from
the distributed rendering plan §15.2).
- Prerequisites table (AWS creds, SAM CLI, bun, repo checkout).
- Three deployment paths: hyperframes lambda CLI (recommended),
direct sam deploy against examples/aws-lambda/template.yaml,
and HyperframesRenderStack CDK construct.
- IAM bootstrap via hyperframes lambda policies user/role/validate.
- Cost shape — how Lambda GB-seconds + SFN transitions roll up
into the displayCost the progress verb prints.
- Troubleshooting block with the typed error names operators
actually hit (PLAN_HASH_MISMATCH, BROWSER_GPU_NOT_SOFTWARE,
iam:CreateRole denial, stuck RUNNING, S3 Retain semantics).
- "What's NOT in v1" callout so adopters don't burn time looking
for webhooks / compositions verb / HDR support.
Registered under a new "Deploy" group in docs.json's Documentation
tab, sitting after Packages so the conceptual flow is "what you
can build" → "how to ship it."
No code changes.
* docs(lambda): address PR review on AWS Lambda deployment guide
One blocker + two important items from Vai's review:
- The BROWSER_GPU_NOT_SOFTWARE troubleshooting entry pointed
adopters at a non-existent `data-gpu-mode` composition attribute.
Replaced with the actual root cause (Chrome launch flags +
@sparticuz/chromium libs in the handler ZIP) and the actual
remediation: rebuild + redeploy via `lambda deploy` (which
always rebuilds the ZIP). The composition-attribute story
would have sent users editing the wrong file entirely.
- Added a `sites create` subsection under Path 1 so adopters
running tight inner loops know how to reuse a project upload
across many renders instead of re-tarring + re-uploading on
each call. The CLI surface was first-class but the doc had
been silent.
- Added a Warning callout under Path 2 explaining that the SAM
template's own ReservedConcurrency default is `-1` (unreserved)
— a reader simplifying the Path 2 example by dropping the
--parameter-overrides flag would silently switch to unreserved
concurrency and pay the runaway-Map cost. The warning mirrors
the cost-shape callout earlier in the page.
* feat(producer): add --mode=lambda-local to the regression harness
Third harness mode that drives the OSS @hyperframes/aws-lambda handler
through the exact event sequence Step Functions produces in
production:
handler({Action: "plan"}) → planDir tarball on fake S3
handler({Action: "renderChunk"}) × N → chunk artifacts on fake S3
handler({Action: "assemble"}) → final mp4/mov/png-sequence
The S3 client is a filesystem-backed fake (every s3://<bucket>/<key>
URI maps to <tempRoot>/s3/<key>), so the harness exercises the
handler's event-parsing + tar/S3 conventions + dispatch logic on top
of the underlying producer primitives. Regressions in event JSON
shape, S3 key layout, or plan-hash boundary checks now surface in
the same CI run as the in-process and distributed-simulated modes
without paying for a real AWS round-trip.
Deliberately NOT a Docker/RIE invocation — that would gate the
producer test suite on Docker-in-Docker support which most CI
runners lack. Real-ZIP-via-RIE tests live in
packages/aws-lambda/scripts/ (probe:beginframe) and the
maintainer-run smoke.sh.
Wired up via:
- HarnessMode union extended to include "lambda-local"
- parseHarnessModeFlag accepts --mode=lambda-local
- regression-harness.ts dispatches to runLambdaLocalRender for
the new mode, sharing the distributed-support gate +
pathology-floor threshold with distributed-simulated mode
- package.json scripts: test:lambda-local + docker:test:lambda-local
- producer.devDependencies += @hyperframes/aws-lambda (workspace)
- producer/tsconfig.json gains path mappings to self so the type
cycle through aws-lambda's source resolves at typecheck time
without needing producer to be pre-built
Tests: 3 new unit tests on parseHarnessModeFlag + resolveMinPsnrForMode
cover the new mode. End-to-end PSNR contract still runs through
Dockerfile.test (manual + CI).
* refactor(producer): /simplify pass on lambda-local harness imports
Three small cleanups on top of the lambda-local harness:
- Drop the unused createReadStream import + its `void` workaround
comment. The aws-lambda handler's tar / S3 transport pulls
createReadStream from its own imports; this file never references
it directly.
- Hoist the dynamic `await import("node:fs")` calls for
writeFileSync out of FilesystemBackedFakeS3.send into the static
import block. Repeated PutObject calls don't need to repay the
dynamic-import cost.
- Hoist the dynamic `await import("@hyperframes/aws-lambda")` call
for untarDirectory similarly. Drops the now-redundant duplicate
aws-lambda import statement.
The PutObject body branch also collapses: `body instanceof Buffer`
and `typeof body === "string"` both call writeFileSync identically,
so they share one branch.
No behavior changes.
* fix(producer): lazy-import lambda-local harness module
The static import of regression-harness-lambda-local.ts pulled
@hyperframes/aws-lambda (and its @aws-sdk/* + @sparticuz/chromium
transitive deps) at module-load time. Dockerfile.test only copies
the producer's own files into the container, so aws-lambda's src
isn't present at runtime — and even `--mode=in-process` failed:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/app/packages/producer/node_modules/@hyperframes/aws-lambda/src/index.ts'
imported from /app/packages/producer/src/regression-harness-lambda-local.ts
Load the module on demand instead. `--mode=lambda-local` callers
pay the import cost; the existing in-process and distributed-
simulated modes don't.
* fix(producer): address PR review on lambda-local harness
Three review items from Vai:
- `Config.width`/`Config.height` are now plumbed through
RunLambdaLocalInput rather than hardcoded inside
runLambdaLocalRender. Lambda-local's whole point is to catch
event-shape drift; if the handler ever starts honouring
Config.width/height (e.g. for canvas sizing), having those
values flow from the caller means the harness sees what the
fixture authored. The interface change makes the eventual
upgrade-to-real-fixture-resolution a one-line dispatch swap.
- Drop the dead `export type { Fps }` and its unused import
from @hyperframes/core. The module never re-exports it.
- The dispatch site in regression-harness.ts now passes 1920×1080
explicitly with a comment marking it as a placeholder until
the harness compiles the composition HTML up-front to surface
the authored data-width/data-height. distributed-simulated
mode uses the same placeholder internally, kept for parity.
No behavior change in the existing modes; lambda-local now has a
clear extension point for honouring fixture dimensions.
* feat(cli): add hyperframes lambda policies role/user/validate
IAM bootstrap subcommand for the lambda CLI. Closes the "first run hits
'User is not authorized to perform iam:CreateRole'" gap that adopters
otherwise have to figure out by hand.
hyperframes lambda policies user
→ prints an inline-policy doc to attach to the IAM user that runs
the CLI
hyperframes lambda policies role --principal=cloudformation
→ prints { TrustRelationship, InlinePolicy } for a service role
cloudformation can assume
hyperframes lambda policies validate ./infra/policy.json
→ diffs a checked-in policy against the CLI's required action set,
expanding s3:* / s3:Get* / * wildcards, exits non-zero on missing
actions (wire it into CI to catch drift before deploys fail)
The required-actions list is derived from what the SAM template at
examples/aws-lambda/template.yaml needs to create plus what
renderToLambda/getRenderProgress call against S3 + Step Functions at
runtime. Sorted alphabetically per-service so diffs stay readable.
Resource is "*" by design — CloudFormation creates new function /
state-machine / bucket ARNs on every adopter's first deploy. The
generated policy is documented as a starting point; adopters with
stricter postures narrow Resource to the deployed ARNs after the
first successful run.
Tests: 10 unit tests covering the action set, doc shape, trust policy
service principal, and validate() against valid / missing / wildcard /
single-Statement / Deny-statement inputs.
* refactor(cli): /simplify pass on lambda policies
Adds a typed TrustPolicyDocument / TrustPolicyStatement pair so
buildRoleTrustPolicy can return a real type instead of unknown. The
trust-policy shape has a Principal field that the generic
PolicyStatement doesn't model, but it was previously punted via a
return unknown rather than a parallel type.
Test cleanup: drop the `as {...}` casts that the previous return-
unknown signature forced.
* fix(cli): address PR review on lambda policies
One blocker + four importants from Vai's review:
- REQUIRED_ACTIONS was missing `s3:ListAllMyBuckets` (called by
`sam deploy --resolve-s3` on first run to discover/create the
`aws-sam-cli-managed-default-*` artifact bucket) and
`cloudformation:ValidateTemplate` (CFN template validation
during change-set creation). Without these, a first-deploy
adopter with the generated policy hits AccessDenied on the
very call the PR was meant to unblock. Added both.
- `policies role --principal=lambda` was a footgun — it produced
a `lambda.amazonaws.com` trust paired with the full deploy
superset, i.e. a confusingly-overscoped Lambda execution role
no human should attach (the SAM template creates its own
scoped execution role automatically). Dropped `lambda` as a
principal option; `policies role` now always emits a
CloudFormation service-role doc.
- `validatePolicy` silently misreported NotAction/NotResource
statements (treating them as zero grants), producing false
negatives. Detect both shapes and surface them via a new
`warnings: string[]` field; NotAction statements are skipped
(rather than producing a false negative), NotResource is
treated as full action grant + a warning.
- Mid-string wildcards (`s3:Get*Object`, `?`) silently failed
the matcher. End-anchored wildcards still work; mid-string
patterns now warn so users know the validator can't expand
them.
- Dropped the dead `samArtifactBucket` action group (fully
subsumed by `s3Bucket` + `s3Object`).
- `validate --json` now wraps errors in a friendly envelope
(`{ ok: false, error: "..." }`) so CI consumers have one
parse shape regardless of failure mode.
- lambda.ts subcommand description and examples updated to
include `policies`.
Tests: 5 new negative-path tests cover NotAction warning,
NotResource warning, mid-string wildcard warning, missing file
(ENOENT), malformed JSON (SyntaxError), and absent Statement
field. All 21 policies tests pass.
* feat(cli): add hyperframes lambda deploy/render/progress/destroy
Wraps the @hyperframes/aws-lambda SDK + the Phase 6a SAM template behind
a single CLI surface so an end-to-end render is three commands instead
of the ~8 manual bun+sam+aws steps the smoke script does today:
hyperframes lambda deploy
hyperframes lambda render ./my-project --width 1920 --height 1080 --wait
hyperframes lambda destroy
Subcommands:
- deploy: build handler.zip + sam-deploy + persist stack outputs
to <cwd>/.hyperframes/lambda-stack-<name>.json
- sites create: pre-upload a project to S3 with a stable content hash
so re-renders skip the tar+PUT pass
- render: start a Step Functions execution; --wait blocks and
streams per-chunk progress + accrued cost
- progress: one-shot snapshot — status, frames, cost breakdown,
errors. Accepts renderId or executionArn
- destroy: sam-delete + drop the local state file (S3 bucket
is Retain'd by the template; documented in --help
and in docs/packages/cli.mdx)
To keep @sparticuz/chromium out of the CLI's transitive deps, this also
adds a dedicated ./sdk subpath export to @hyperframes/aws-lambda; the
CLI imports from @hyperframes/aws-lambda/sdk exclusively. The existing
. barrel still re-exports both handler + SDK for adopters who want one
entry point.
Defaults are deliberately cost-conservative for first-time users:
--concurrency=8 (low enough to never surprise) and --memory=10240 (the
common case; documented for adopters who want to tune down).
Tests: 5 unit tests on the state-file round-trip. CLI integration
against sam local invoke is part of the upcoming PR 6.6 (lambda-local
regression harness).
* refactor(cli): /simplify pass on the lambda command group
Two small cleanups on top of the lambda CLI:
- Replace parseFormat / parseCodec / parseQuality / parseChromeSource
(four near-identical helpers) with a single generic parseEnum() +
typed const-tuple lookups. The four callers now read as one-line
arrow functions that lift the allowed values out of the function
body so they're easy to extend.
- DEFAULT_STACK_NAME was const-declared then re-exported at the
bottom of state.ts; just mark the const export inline.
No behavior changes. All CLI tests still pass.
* fix(cli): keep @hyperframes/aws-lambda external in the tsup bundle
esbuild can't bundle @hyperframes/aws-lambda's transitive AWS SDK
deps (@aws-sdk/* + @smithy/*) cleanly into a node binary — the
SDK's .browser.js conditional re-exports break the resolver:
ESM Build failed
No matching export in "splitStream.browser.js" for import
"splitStream" (and ~10 similar errors)
Mark aws-lambda as `external` so esbuild doesn't follow it, and
move it from devDependencies to dependencies so the published CLI
can resolve it from node_modules at runtime. The lambda subverb
files dynamic-import only on `hyperframes lambda *` invocation, so
the CLI cold-start cost is unchanged.
The install-size hit (AWS SDK + @sparticuz/chromium ≈ 200 MiB) is
documented as a v1 tradeoff; a future split into a lambda-sdk-only
subpackage can pare this back.
* fix(cli): address PR review on lambda CLI
Two blockers + four important items from Vai's review:
- `--memory` was parsed and recorded in the local state file but
never forwarded to `sam deploy` as a parameter override. Worse,
`progress.ts` then read the *recorded* value for cost math, so
`--memory 5120` produced wrong cost numbers downstream. Thread
`LambdaMemoryMb` through samDeploy's --parameter-overrides.
- `--profile` was only consumed by deploy / destroy. render and
progress fell back to the default credentials chain — a user
with `--profile prod` would silently render against their
default account (wrong-account billing footgun). Set
`process.env.AWS_PROFILE` (and `AWS_REGION`) in the dispatcher
before any subverb runs; the AWS SDK reads them natively, so
render / progress / sites all benefit without each subverb
threading the flag through the SDK call.
- `--profile` + destroy now also reads `process.env.AWS_PROFILE`
as a fallback (matching deploy's existing env fallback).
- `--wait --json` printed both the start handle AND the final
progress snapshot, producing two concatenated JSON blobs that
`jq` rejected. Now emits a single document: handle (without
--wait) OR final progress (with --wait).
- Negative integers on `--width` / `--height` / `--chunk-size` /
`--max-parallel-chunks` / `--memory` / `--concurrency` now fail
loudly via a new `parsePositiveInt` wrapper instead of flowing
into the SDK and producing opaque AWS validation errors mid-
render.
- `DEFAULT_STACK_NAME` is now centralized to the literal
`"hyperframes-default"` and consumed from one place. Previously
the value was assembled as `hyperframes-${"default"}` in three
sites and hardcoded as `"hyperframes-default"` in a fourth.
`requireStack`'s hint now matches the dispatcher's default.
The faked `SiteHandle` for `--site-id` keeps the documented
placeholder fields but also surfaces `bucketName` (from PR 909's
extended SiteHandle interface), matching the SDK contract.
All CLI unit tests + the full bundler build still pass.
* fix(cli): keep aws-lambda out of CLI runtime deps
The "Smoke: global install" CI step packs the CLI via `npm pack` and
installs it globally via `npm install -g <tgz>`. npm doesn't understand
the workspace: protocol, so a runtime `dependencies` entry of
`@hyperframes/aws-lambda: workspace:*` blows up with:
npm error code EUNSUPPORTEDPROTOCOL
npm error Unsupported URL Type "workspace:": workspace:*
(pnpm rewrites workspace:* on publish; npm pack doesn't.)
Three changes to unblock the smoke + keep the published CLI install
small for users who don't deploy to Lambda:
- Move `@hyperframes/aws-lambda` from CLI's `dependencies` back to
`devDependencies`. It's already external in tsup.config.ts; the
bundle references it via runtime resolution only.
- Convert the static `import { … } from "@hyperframes/aws-lambda/sdk"`
in sites.ts / render.ts / progress.ts to `await import()` inside
each function. tsup with `splitting: false` was inlining those
static imports at the top of the bundle, which made Node eagerly
resolve them at CLI startup (MODULE_NOT_FOUND before any lambda
subcommand even runs). Dynamic imports stay dynamic in the bundle.
- Add a friendly missing-module check in the lambda dispatcher.
When a user runs `hyperframes lambda deploy / render / sites /
progress / destroy` without aws-lambda installed, they now see:
@hyperframes/aws-lambda is not installed.
The `hyperframes lambda deploy` command needs it at runtime.
Install it alongside the CLI:
npm install -g @hyperframes/aws-lambda
Verified locally: pack + global install + `hyperframes init --example
blank` now succeeds end-to-end (was the same scenario the CI smoke job
runs).
* feat(lambda): add TypeScript SDK and CDK construct
Adds the client-side surface on top of the Phase 6a Lambda handler so
adopters can drive a deployed stack from Node without writing AWS-SDK
boilerplate:
- renderToLambda(opts) starts a Step Functions execution and returns a
handle. Does NOT poll.
- getRenderProgress({ executionArn }) returns a snapshot of progress,
frames rendered, cost (Lambda GB-seconds + SFN transitions), errors,
and the final output object once Assemble completes.
- deploySite({ projectDir, bucketName }) content-addresses the project
tree, tar.gzs it, and uploads to S3 with a HeadObject short-circuit so
re-renders of the same tree skip the tar+PUT.
- validateDistributedRenderConfig throws a typed InvalidConfigError
before StartExecution, so shape errors surface synchronously.
- computeRenderCost is exposed for callers who want to format cost out
of band.
Also ships HyperframesRenderStack, an aws-cdk-lib L2 construct that
emits the same topology as examples/aws-lambda/template.yaml. Lives on
the ./cdk subpath export so SDK-only consumers don't pull aws-cdk-lib
into their runtime graph (declared as an optional peer dependency).
Tests: 24 new unit tests across the SDK plus 9 CDK synth / contract /
snapshot tests. All 83 tests in packages/aws-lambda/src pass.
* refactor(lambda): /simplify pass on the SDK + CDK PR
Pulls shared logic out so the SDK doesn't re-invent things the handler
and the producer already have:
- `formatExtension` extracted to packages/aws-lambda/src/formatExtension.ts.
handler.ts and renderToLambda.ts both used identical 12-line copies of
this switch.
- `PLAN_PROJECT_DIR_SKIP_SEGMENTS` is now exported from
@hyperframes/producer/distributed. deploySite consumes it instead of
its own duplicate SKIP_TOP_LEVEL set; the two lists were trivially
identical and would have drifted silently.
- `FakeS3` + `drainBody` factored out of the two SDK test files into
src/sdk/__fixtures__/fakeS3.ts. Drops ~110 lines of test-file
duplication and gives future SDK tests a one-line FakeS3 import.
- S3 URI building in deploySite and renderToLambda routes through the
existing `formatS3Uri` helper instead of inline `s3://...`
concatenation; matches the convention already in handler.ts.
Net -133 lines across the touched files. All 83 aws-lambda tests still
pass; all 60 producer distributed tests still pass.
* fix(lambda): bump CDK test timeouts for CI cold-start synth
The bun:test default 5s timeout tripped the first CDK snapshot test
in CI when the cold-start `Template.fromStack(stack)` synth took ~5-8s
on the slowest GitHub Actions runner. Locally on a warm shell the
synth measures <1s, so the failure didn't reproduce until PR #909 hit
CI.
Two changes:
- Both CDK test files cache one synth in `beforeAll(..., 30000)` and
reuse the result across every test that uses the default props.
Each individual test now runs in microseconds (pure assertions
against the already-synthed template), so the 5s timeout no longer
applies on the hot path.
- The two contract tests that exercise non-default props
(reservedConcurrency, projectName) still synth fresh per-test; they
get a per-test `it(..., 30000)` timeout.
No behavior changes.
* fix(lambda): address PR review on SDK + CDK construct
Three correctness + ergonomics fixes raised in Vai's review:
- getRenderProgress over-counted SFN transitions by 3-5×. Step
Functions Standard Workflows bill per state-entry, not per
history event. Each Task produces ~5-7 history events
(Scheduled / Started / Succeeded / TaskStateExited / …);
counting `events.length` reported the runaway. Switch to
counting `*StateEntered` events explicitly.
- assembleComplete + outputFile detection was coupled to the
Lambda payload's `Action` field. Move both signals onto the
enclosing state name (`StateExited.name === "Assemble"`), which
is the state-machine identity rather than the Lambda event
contract. framesRendered increment moves to the same boundary
(RenderChunk state).
- SiteHandle now carries `bucketName` directly so README + CLI
callers don't have to re-parse `projectS3Uri.split("/")[2]`.
Test updates: getRenderProgress tests wrap renderChunk/assemble
events in matching StateEntered + StateExited pairs so the new
state-name-driven dispatch is exercised end-to-end. SiteHandle
fixture in renderToLambda.test.ts gets the new bucketName field.
All 83 aws-lambda tests still pass.
* perf(distributed): parallelize chunk capture across multiple workers
The distributed `renderChunk` primitive hardcoded `workerCount: 1` and
`captureStage` explicitly forbade `workerCount > 1` when `frameRange` was
set, with the comment:
"Distributed chunk workers fan out at the activity layer; reduce
workerCount to 1 when passing frameRange."
The assumption was that orchestration-layer fan-out (Temporal / Lambda /
K8s Jobs / SSH) saturates the available CPU on its own. In practice
adopters that deploy chunks onto multi-core hosts (8-24 vCPU is the
standard producer-worker pod sizing) end up pinning only ~3-4 cores per
chunk while the rest sit idle: chunk-level fan-out at the orchestration
layer gives each pod one chunk at a time, and the chunk render itself
was single-threaded.
Validated against a real 1080p / 30fps / 22-second shader-heavy
composition on a 22-vCPU Temporal pod: each chunk rendered at
165-273ms per frame (vs 94-98ms for the in-process streaming render
which runs `workerCount=2` by default). The slowest chunk gates total
wall-clock under parallel chunk fan-out, so the 2-3x per-frame gap
compounds and `distributed` was net-slower than `in-process` on every
composition smaller than ~5min of texture-class content. Lifting the
restriction is a measured ~2x per-chunk speedup with no contract
change at the framesDir or encoder layer.
Wire-up:
* `WorkerTask.outputFrameOffset` — optional offset subtracted from the
absolute frame index when computing the captured file's name.
Default 0 (the in-process contract; file name == absolute index).
Distributed chunks set this to the chunk's startFrame so file names
land 0-indexed within the chunk's range, matching the sequential
chunk-capture contract and the encoder's expectation that frames
are read sequentially without an `-start_number` override.
* `distributeFrames(totalFrames, workerCount, workDir, rangeStart=0)` —
offsets both `startFrame`/`endFrame` (used for per-frame time math
on the page's virtual clock) by `rangeStart`, and threads
`outputFrameOffset = rangeStart` onto each task it emits. With the
default `rangeStart=0` it is a no-op for in-process renders.
* `executeWorkerTask` — uses `i - (task.outputFrameOffset ?? 0)` for
the captured file name, leaving the per-frame TIME computation
`(i * fps.den) / fps.num` untouched so the page's virtual clock is
unchanged.
* `executeDiskCaptureWithAdaptiveRetry({ frameRangeStart? })` — accepts
the chunk's absolute startFrame and forwards it to `distributeFrames`
and `buildMissingFrameRetryBatches`. Default `undefined` preserves
the in-process contract.
* `buildMissingFrameRetryBatches(ranges, ..., rangeStart=0)` —
`findMissingFrameRanges` walks LOCAL 0-indexed file names; the retry
batch translates the local missing-range pair back to ABSOLUTE
composition indices for `WorkerTask.startFrame/endFrame` and sets
`outputFrameOffset = rangeStart` so the retried capture writes back
to the same local file name.
* `captureStage` — drops the assert; passes
`frameRangeStart: frameRange?.startFrame` to the parallel branch so
workers land on absolute composition frame indices for time math
while file names stay 0-indexed within the chunk range. Docstring
updated to reflect that the parallel branch is now supported.
* `renderChunk` — `workerCount: 1` → `workerCount: 2`. The pre-warmed
`probeSession` is consumed only by the sequential branch; the
parallel branch closes it during stage entry and creates its own
worker sessions. Documented as a follow-up: skip probeSession
creation when `workerCount > 1` to recover the ~3-5s warmup cost.
Backwards compatibility: every change is gated on a parameter that
defaults to the prior behavior. In-process callers (`executeRenderJob`)
pass no `frameRangeStart`, so `rangeStart === 0`, `outputFrameOffset`
defaults to 0, and the file-name math collapses to the prior `i` value.
The framesDir contract (`frame_0..frame_(totalFrames-1)`) and the
WorkerTask interface are extended, not replaced.
Tests: 24 pass / 0 fail across the distributed test suite (renderChunk,
plan, assemble, planFormatBanlist, planSizeCap, publicExports). 7 pass /
0 fail in `parallelCoordinator.test.ts`. The renderOrchestrator suite
has one pre-existing Windows-only failure
(`writeCompiledArtifacts — external assets on Windows drive-letter
paths`) unrelated to this change; the other 56 tests pass.
Refs: distributed-vs-inprocess benchmark thread at
heygen-com/experiment-framework#36950
* perf(distributed): auto-size chunk workerCount via calculateOptimalWorkers
Match the in-process renderer's worker selection instead of hardcoding 2.
`calculateOptimalWorkers(framesInChunk, undefined, cfg)` is the same call
`resolveRenderWorkerCount` makes under the hood, minus the capture-cost
calibration reduction (which would require plumbing the chunk's compiled
metadata through — left as a follow-up).
For a typical 22-vCPU producer-worker pod with `cfg.concurrency: "auto"`
this resolves to ~6 workers for a 240-frame chunk (capped by
`defaultSafeMaxWorkers() = max(6, min(16, floor(cpuCount/8)))`), matching
what `executeRenderJob` (the in-process path) already does. The prior
hardcoded `workerCount: 2` was a safe-minimum starting point that
undersized chunks vs prod's auto behavior.
Tests: 12/12 pass in `renderChunk.test.ts` (unchanged — the test suite
mocks the inner runCaptureStage call so workerCount selection is opaque
to it).
* refactor(distributed): /simplify pass on PR #906
Review pass on the parallel-capture frame-range change. Four targeted
cleanups identified by code-quality and efficiency review agents:
1. Add the missing `frameRange.endFrame - frameRange.startFrame === totalFrames`
assert. The parallel branch forwards `totalFrames` separately from
`frameRangeStart`; a caller passing mismatched values would have got a
silently wrong distribution. The sequential branch already implicitly
relied on this via its `rangeFrames = rangeEnd - rangeStart` arithmetic.
2. Collapse three near-duplicate docstrings (on `WorkerTask.outputFrameOffset`,
`executeDiskCaptureWithAdaptiveRetry.frameRangeStart`, and `runCaptureStage`'s
`frameRange`) so only the WorkerTask field carries the full contract. The
other two cross-reference it.
3. Drop the WHAT-narrating comments inside `executeWorkerTask`'s per-frame
loop. The variable names (`fileFrameIdx = i - outputOffset`) already say
what the line does; the only remaining comment flags the non-obvious
contract that the streaming callback gets the absolute index.
4. Trim the 30-line `chunkWorkerCount` block in `renderChunk` to one paragraph
explaining the one non-obvious thing (why we use `calculateOptimalWorkers`
directly instead of `resolveRenderWorkerCount`). The probeSession-wasted-on-
parallel acknowledgement stays as a 3-line follow-up flag — investigated
skipping it in this pass, but the SwiftShader probe is safety-critical and
has no per-worker equivalent, so deferred to a separate change with proper
per-worker assertion plumbing.
Tests + format + lint clean:
* `bun test parallelCoordinator.test.ts` — 7/7
* `bun test distributed/{renderChunk,plan}.test.ts` — 24/24
* `bunx oxfmt` + `bunx oxlint` — clean
* feat(lambda): add real-AWS smoke + benchmark script
Phase 6.3 of the distributed rendering plan
(DISTRIBUTED-RENDERING-PLAN.md §11 Phase 6a). Local bash script that
deploys the PR 6.2 SAM template to your own AWS account, renders a
fixture composition through the Step Functions state machine at several
chunk counts, PSNR-compares each output against the in-process
baseline, and tears the stack down.
This is the gate that proves the architecture works on real Lambda
infrastructure. After it runs green and the numbers are good, Phase 6b
(CLI, CDK, docs) can proceed with confidence.
Script lives at examples/aws-lambda/scripts/smoke.sh. Defaults:
- fixture: mp4-h264-sdr
- chunk_counts: 2,4,8
- psnr-threshold: 50 dB
- region: us-east-1
- stack-name: hyperframes-lambda-smoke-<timestamp>
AWS credentials come from the standard resolution chain (env vars,
~/.aws/credentials, SSO, IMDS). Pin a specific profile via --profile or
AWS_PROFILE; the script doesn't ship a default.
Workflow:
1. Pre-flight: verify aws/sam/bun/ffmpeg/jq/zip on PATH; check
credentials via sts:GetCallerIdentity.
2. Build the PR 6.1 ZIP and run verify:zip-size.
3. sam validate --lint + sam deploy under a per-run stack name.
4. Zip the fixture's src/ and upload to the render bucket.
5. For each chunk count, start a Step Functions execution, poll for
completion (25-min cap), download the output mp4 and the execution
history JSON, ffmpeg-psnr against the LFS-tracked in-process
baseline, and append to results.json.
6. Gate on the PSNR threshold.
7. Empty the bucket + sam delete (unless --keep-stack).
Outputs land under ./lambda-smoke-artifacts/:
- results.json (chunkCount x wallClockMs x psnrAvgDb)
- renders/N<N>-output.mp4
- renders/N<N>-history.json (full Step Functions execution history)
Per-run stack name with concurrency-safe AWS resource isolation. Run
multiple smokes in parallel without races; teardown guards against
stale stacks via cleanup_and_exit on every failure path.
Distinction from CI: this is a maintainer-run gate, not part of regular
CI. The architecture's per-PR safety net is the local Docker-based
BeginFrame probe (PR 6.1) and the upcoming Lambda RIE smoke mode (PR
6.6). No GitHub Actions / OIDC / cross-account secrets required.
This is part of the 8-PR Phase 6 stack; PR 6.3 of 8 — the last PR of
Phase 6a (validation). Phase 6b (CLI + CDK + docs) starts once 6.3's
benchmark numbers come back.
* fix(lambda): address PR 880 review feedback
- Document wall-clock methodology bias inline (eval.sh header + README):
local timing includes bun + tsx + harness scaffolding while Lambda
timing measures pure SFN execution, so "speedup" is end-to-end CLI
experience, not renderer-vs-renderer.
- Add --iterations N (default 1) with median wall-clock reporting via
awk-side median. Cold-start variance is ±5-10s per chunk; single-
sample readings made the PR-body speedup table not ground truth.
- Add --reserved-concurrency flag to both scripts; default still 16 but
no longer hardcoded. Pass-through to ReservedConcurrency CFN param.
- README: cost-per-pass estimate for both scripts.
- Replace `sed -n '2,30p' "$0"` help with usage() heredoc in both
scripts — fragile to header reflows and didn't survive the comment
expansion this commit adds anyway.
- eval.sh RMS-level parser: add a third fallback (`RMS level:` with no
`dB` suffix) for older ffmpeg builds where astats predates the unit
tag. Word-boundary guards keep `RMS peak level` from being eaten.
* docs(lambda): drop internal plan-doc + Rio refs from smoke/eval scripts
* feat(lambda): add SAM template and sample events for AWS deployment
Phase 6.2 of the distributed rendering plan (DISTRIBUTED-RENDERING-PLAN.md
§15). Reference SAM template for deploying HyperFrames distributed
rendering on AWS — one Lambda function in three roles, choreographed by
a Step Functions standard workflow with a Map state for parallel chunk
rendering.
Resources created by the template:
- Lambda function pointing at the Phase 6.1 ZIP
- Step Functions state machine: Plan -> Map(N) RenderChunk -> Assemble
- S3 bucket for plan tarballs, chunk outputs, final mp4
- IAM role for the state machine
- CloudWatch alarm guarding against runaway chunk invocations
Retry policy: 4 attempts, 2s initial, 2x backoff, max 60s, with the
typed non-retryable error codes from plan §9.3 explicitly opted out.
CodeUri points at packages/aws-lambda/dist/handler.zip; sam deploy
resolves the local path and uploads to a SAM-managed bucket on first
deploy.
Validated: sam validate --lint passes against the template.
This is part of the 8-PR Phase 6 stack; PR 6.2 of 8.
* fix(lambda): address PR 879 review feedback
- Add CloudWatch alarms for Lambda Errors metric (5min window, threshold 1)
and Step Functions ExecutionsFailed metric. The existing runaway-
invocations alarm catches too-many-calls but missed silent per-chunk
failures and retry-exhaustion.
- Document VersioningConfiguration: Suspended tradeoff inline. Adopters
treating the final mp4 as user-keepable should bump to Enabled.
- Cost-allocation Tags on RenderBucket + Lambda Globals.
- Lambda Tracing: Active so X-Ray spans don't terminate at the SF→Lambda
boundary (the state machine already had tracing).
- State-machine top-level TimeoutSeconds: 3600 as defensive ceiling on
the whole choreography — catches Plan-retry storms before they hit
individual task budgets.
- AssertChunkCount Choice state: if Plan ever returns ChunkCount=0 the
Map would silently iterate zero times and Assemble would receive an
empty ChunkS3Uris[] producing an empty output. Fail-fast with typed
PLAN_TOO_LARGE error instead.
- Architecture comment: explicit x86_64-only constraint from
@sparticuz/chromium so adopters trying Graviton don't get bitten.
* docs(lambda): drop internal plan-doc refs from SAM example + template
* feat(lambda): add Lambda handler, ZIP bundling, and BeginFrame probe
Phase 6 of the distributed rendering plan: AWS Lambda turnkey adoption
(see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 6 + §15).
This PR adds the new packages/aws-lambda/ workspace package that wraps
the OSS plan/renderChunk/assemble primitives in an AWS Lambda handler,
plus a build pipeline that bundles the handler + Chromium runtime +
ffmpeg into a deployable ZIP.
Architecture: ZIP deploy (not Docker image), Chrome via @sparticuz/chromium
with chrome-headless-shell fallback, dispatch on event.Action ∈ {plan,
renderChunk, assemble}.
The load-bearing concern — does @sparticuz/chromium's chrome-headless-shell
build honour CDP HeadlessExperimental.beginFrame? — is pinned by the new
scripts/probe-beginframe.ts regression guard. Probe boots the runtime
inside public.ecr.aws/lambda/nodejs:22, navigates to a static page, and
asserts beginFrame returns a PNG buffer. Verified locally + inside the
Docker container; both pass with hasDamage=true.
Sizes (sparticuz source): unzipped 157 MiB, zipped 99 MiB. Well under
the 240 MiB / 150 MiB in-house gates and the Lambda 250 MiB hard ceiling.
This is part of a stack of 8 PRs (3 in Phase 6a, 5 in Phase 6b); this is
PR 6.1.
* fix(lambda): address PR 878 review feedback
- Verify event.PlanHash against the untarred plan.json at the handler
boundary before invoking the producer primitive. Throws typed
PLAN_HASH_MISMATCH on divergence so Step Functions routes it as
non-retryable; previously the field was schema bloat the handler
ignored, leaving enforcement entirely inside the producer.
- Standardize on MiB throughout build-zip.ts, verify-zip-size.ts, and
the README. Lambda's hard ceiling is 250 MiB (AWS docs label "250 MB"
but use binary mebibytes); previously mixed units made the 248 MiB
budget look like a ~5 MB margin instead of the 2 MiB it actually is.
- stageChromeHeadlessShell now picks Chrome versions via numeric semver
comparison instead of lexicographic sort+reverse — the latter would
silently pick "99.x" over "131.x" once Chrome cached three-digit
majors that aren't width-aligned.
- Drop _setSparticuzChromiumForTests from the public index barrel.
Test-only DI seam imported directly from ./chromium.js in tests.
- Replace require("node:fs") inside walkSize() with the top-level fs
imports — file is ESM and the same module is already imported.
* docs(lambda): drop internal plan-doc refs from package README
* ci(windows): fix bun filter UNION bug excluding producer from Windows tests
`bun run --filter "!a" --filter "!b" test` composes as a UNION (any
package matching either negation runs), not an intersection. Effect:
@hyperframes/producer was still being tested on Windows even though
it's explicitly excluded — its regression harness (Docker + LFS golden
mp4 baselines) is Linux-only and was driving the 32min timeout.
Enumerate the packages we DO want to test instead.
* feat(producer): add Rio-style residual-RMS check to regression harness
The existing audio comparison in the regression harness measures the
Pearson correlation between RMS envelopes of the rendered and snapshot
streams. That catches shape-level drift but is insensitive to level
shifts, phase offsets, or codec-quantization noise — two streams can
correlate >0.9 while differing audibly.
Rio's approach (rio/tests/checksum.py:compare_audio_files_ffmpeg) is
sample-level: subtract the snapshot from the rendered stream, run
`astats`, read the residual Overall RMS in dBFS. Identical streams
cancel to silence (-inf, or sub -90 dBFS for AAC-vs-AAC); anything
>= -50 dBFS is considered drift.
This commit adds the same check as an optional secondary gate:
- utils/audioRegression.ts: new `computeAudioResidualRmsDb()` that
spawns ffmpeg with the same filter graph Rio uses (aresample +
pan + volume=-1 + amix + astats) and returns the parsed Overall
RMS plus a pass/fail flag.
- utils/audioRegression.test.ts: 3 new tests covering identical
streams (-inf result), drifted streams (440Hz vs 880Hz sine),
and missing-audio-stream input.
- regression-harness.ts: optional `maxAudioResidualRmsDb` field in
meta.json. Default is undefined (skip the check) so legacy
fixtures aren't retroactively gated; new fixtures opt in by
setting a threshold (e.g. -50). Harness emits `residualRmsDb` in
the audio_comparison_complete JSON event and the pretty log line.
The existing correlation check stays in place; the new residual check
is independent. They measure complementary properties (shape vs
sample-cancellation) and both should hold for a faithful render.
* fix(producer): harden residual-RMS check (parser, duration guard, error surfacing)
Addresses review feedback on PR #882:
- Stateful astats parse: modern ffmpeg emits `Overall` on its own line
followed by per-stat lines, so the single-line `Overall RMS level dB:`
regex never fires on 6.x/7.x/8.x. Find the `Overall` header, take the
next `RMS level dB:` line. Single-line fallback preserved for 4.x.
- Pre-probe both inputs' audio durations and fail up-front if they differ
by >5 ms — `amix=duration=shortest` was silently masking trailing
audio differences.
- Surface ffmpeg/ffprobe spawn errors, signal kills, and non-zero exits
with a stderr tail. Previously every failure mode collapsed into
"NaN, fail" with no diagnostic.
- Extend `TestResult.audio` with `residualRmsDb` + `residualError`,
propagate to `audio-failures.json`.
- Fix `residualSuffix` formatter: NaN (real failure) was being rendered
as "-inf dBFS" (perfect match). Split the branch on `Number.isNaN`
separately from `Number.isFinite` and add an explicit error label.
The producer source + docs referenced an internal coordination doc
(DISTRIBUTED-RENDERING-PLAN.md) that doesn't ship in the OSS repo,
leaving broken cross-links for adopters. Drops the references and the
bare section-number shorthand that depended on them; behavioural
content (hash contract, retry semantics, threshold rationale) is
preserved inline where it was previously offloaded to a section number.
Same 5-step preflight body (setup-bun, setup-node, cache, install,
lint, format:check) was duplicated across 5 workflows. Move it to
.github/actions/preflight/action.yml so future tweaks (adding
typecheck, swapping the cache key, etc.) are a single-file change.
Net diff: +33 / -65.
Addresses the "shared preflight" follow-up Vai called out on #877.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each of the 5 preflight gates was doing a cold bun install, costing
~30-60s of redundant install time per PR. Cache the install dir
keyed on bun.lock so subsequent preflights (and reruns) hit warm.
Addresses Vai's review on #877.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Don't burn 60+ runner-minutes on regression shards, perf shards,
preview-parity, Windows renders, or catalog-preview renders when
the PR is already failing lint or format.
- regression: matrix fail-fast: false → true (first failing shard
cancels the rest), plus a new preflight (lint + format:check)
job gating regression-shards.
- player-perf: matrix fail-fast → true, plus preflight gate.
- preview-regression, windows-render, catalog-previews: preflight
gate added; heavy jobs now needs: [..., preflight].
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address @vanceingalls and @miguel-heygen review findings on #852:
1. Asymmetric soft-skip — only the N=1 plan+render+assemble call was
wrapped in the host-Chrome-failure catch; an SwiftShader / cold-Chrome
flake on the N=4 call would hard-fail instead of soft-skip. Factor a
local runRender() helper and wrap both calls.
2. Vacuously-passing length assertion — 'expect(framesOne.length).toBe(
framesFour.length)' passes when both runs produce 0 frames. Pin the
absolute count (EXPECTED_FRAME_COUNT = 60) so a regression that
identically truncates both renders shows red.
3. CDN version drift — anime-boundary loaded gsap@3.14.2 from jsdelivr
while every other boundary fixture loaded 3.12.2 from cdnjs. Unify on
cdnjs@3.12.2 so the next reader doesn't have to wonder why one fixture
diverges. (gsap is an empty duration-driver in all six fixtures so
the version was never load-bearing — but the divergence reads as
intentional and isn't.)
4. VIDEO_EXT type narrowing — the lookup is Record<"mp4"|"mov"|"webm">
but outputFormat includes "png-sequence". The isPngSequence ternary
short-circuits before png-sequence can reach the indexing site, but TS
can't narrow through that. Add an explicit cast at the indexing site
(not the lookup definition — over-widening to include "png-sequence":
undefined would defeat the existence guarantee).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Address findings from a three-agent code-review pass over the Phase 4 stack:
- regression-harness: hoist `readdirSync` out of the per-checkpoint
failure-extraction loop (was running 20 redundant syscalls on every
failing png-sequence test). Drop redundant `existsSync` guards before
`mkdirSync(recursive: true)` and `rmSync(force: true)`. Replace the
three-deep ternary that built the output filename suffix with a
single `Record<format, ext>` lookup.
- regression-harness-distributed: flatten the `format === "mp4" ? {...} : {...}`
branching in the `plan()` call into a single config object with a
conditional spread. `plan()` already accepts `codec: undefined` for
non-mp4 formats, so the duplicate object was unnecessary.
- chunkBoundary.test: rename the stale "byte-identical mp4" test title
to "byte-identical frames" (the test now uses png-sequence). Trim the
10-line comment justifying `rejectOnSystemFonts: false` to the
essential WHY.
- renderChunk / plan.test / regression-harness: drop trailing-edge
comment phrases that pinned the prose to the PR's calendar context
("today", "v1.5", "pre-codec-knob output", section-numbered cross-
references to the planning doc).
No behavior change. All 49 distributed unit tests pass. Smoke + four
distributed format fixtures pass in --mode=distributed-simulated.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Address @vanceingalls review on #851:
1. validateMetadata's codec/format check read 'rc.codec !== undefined &&
rc.format !== undefined && rc.format !== "mp4"'. The behavior was
correct (omitted format defaults to mp4 downstream so codec is legal)
but relied on the reader knowing that default. Normalize 'effectiveFormat
= rc.format ?? "mp4"' before the comparison so the intent reads
directly.
2. The mp4-h264-sdr sibling carries inline rationale for the no-audio
choice (AAC frame quantization extends container.duration past
nb_frames/fps and trips the harness PSNR sampler) and the chunk-seam
mapping (crossfade window 0.9-1.1s straddles frame 30). mp4-h265-sdr
stripped both. Carry them back so the two fixtures stay parallel.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The generic parameter constraint exceeded oxfmt's line width, so the
formatter wraps the type-param list onto its own line. Applies the same
formatting locally that CI's 'Format' job would have produced via
'bun run format:check' — no behavior change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Address @vanceingalls review on #850:
1. Unknown codec strings (typos like 'H265', future additions like 'av1')
silently fell through to libx264 in resolveEncoderTriple. Add an
explicit throw symmetric to the non-mp4-format branch already there.
A JS caller building config from JSON who passes 'codec: "h266"'
now gets a clear error at plan time instead of unflagged h264 output.
2. The preset.codec override in renderChunk had no fast unit coverage —
only the heavyweight Docker fixture in #851 would catch a regression
if someone refactored the spread (e.g. moved it into getEncoderPreset
itself). Extract resolvePresetForLockedEncoder() and add 4 fast unit
tests pinning the four encoder shapes (libx265/libx264/prores/png-seq).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Address @vanceingalls review on #848: the mp4-h264-sdr sibling fixture
explains the crossfade-straddles-frame-30 and continuous-rotation
chunk-seam design choices inline; mov-prores didn't. Add the parallel
comment so the next contributor reading either fixture finds the same
context. Notes specifically that ProRes is intra-only and therefore
exercises the QuickTime atom / -c copy contract rather than frame-level
state continuity.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Address @vanceingalls review on #847: the maxFrameFailures=0 byte-identity
threshold will fail when Chromium's CDP screenshot bytes or libpng's
deflate output shifts on a Docker image bump. Pin the recovery procedure
in the fixture's description so a future on-call sees 'regenerate
baselines' rather than spending time investigating a non-regression.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Address @vanceingalls review on #845: the new discoverTestSuites
dispatch was silently allowing a future tests/distributed/<x>/ fixture
to collide with an existing tests/<x>/ fixture of the same name. Both
would push under the same suite.id and stomp each other's failures/
output, baseline lookup, and CLI --filter match.
Detect the collision at discovery time and throw with both source dirs
named, so the conflict is fixable at author time. Easier to enforce now
(one fixture in the new namespace) than after the rest of the Phase 4
fixtures land.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Two surgical changes, both isolated to the catalog-previews flow:
1. `packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts`
PR #842 changed `seek()` to take `(time, { keepPlaying: true })` for the
A/E shortcuts. The keyboard-layout tests added by #839 still asserted the
single-arg form. Both landed on main without cross-checking, so `main`
itself has been failing Test/Windows since. Update the two assertions
to match the new signature. Same fix Miguel already authored on
`feat/studio-preview-pasteboard-bg`.
2. `registry/components/vignette/demo.html`
The original demo captured a frame where the vignette was at its
weakest point — the effect was nearly invisible in the static preview
used by docs. Reworked the demo so:
- The backdrop is a layered "cinematic still" (warm key + teal rim +
dark falloff) and includes a centered subject ("moon"), so the
vignette has a focal point to frame.
- Vignette starts soft (size 70%, alpha 0.35) and ramps to a dramatic
cinematic vignette (size 26%, alpha 0.92) over 1.6s.
- Peak intensity holds across t≈3.0s, which is exactly where the
catalog script samples the thumbnail (`Math.min(3.0, duration*0.6)`
with duration=5).
- Breathing motion in t=3.4–5.2s gives the video loop visible life
without disturbing the still frame.
`scripts/generate-catalog-previews.ts` still called `createCaptureSession`
with `fps: 30` and `createRenderJob` with `fps: 24`. Since commit 5dcc89c9
("feat(cli): accept ffmpeg-style rational fps") `CaptureOptions.fps` and
`RenderConfig.fps` are `Fps = { num, den }` rationals — a plain number
yields `options.fps.den === undefined` and:
```ts
beginFrameIntervalMs: (1000 * options.fps.den) / Math.max(1, options.fps.num),
// = (1000 * undefined) / Math.max(1, undefined) = NaN / NaN = NaN
```
After warmup, `session.beginFrameTimeTicks = (baseTickCount + 10) * NaN = NaN`,
and the next `HeadlessExperimental.beginFrame` CDP call fails with:
```
Protocol error (HeadlessExperimental.beginFrame): Invalid parameters
Failed to deserialize params.frameTimeTicks - BINDINGS: double value expected
```
This regression didn't surface earlier because the Catalog Previews workflow
only re-renders items whose files changed in the PR, so existing components
were never exercised against the new fps contract. The vignette addition is
the first new item since the refactor.
Fix: pass `{ num: 30, den: 1 }` and `{ num: 24, den: 1 }`.
Miguel (approved) and Vai (commented) both flagged the same
PSNR-threshold doc/code mismatch; Vai additionally flagged a
path-anchoring bug in the projectDir-copy filter and a dishonest type
cast. Addressed all five findings:
PSNR threshold doc/code mismatch (important):
- Module docstring, `resolveMinPsnrForMode` JSDoc, and tests/README.md all
claimed distributed-simulated tightens to ≥50 dB. The actual code uses
`max(fixture.minPsnr, 10)` — 10 dB is a pathology floor, the per-test
gate is the fixture's authored `minPsnr`. Updated all three doc sites
to describe what the code does. The 50 dB target in §5.1 is a per-
render distributed-vs-in-process contract; against the frozen baseline
it's unreachable for either mode (shared encoder/JPEG jitter), so it
can't be a per-fixture gate.
`PLAN_PROJECT_DIR_COPY_SKIP` regex matched absolute paths (important):
- `cpSync` calls the filter with the absolute source path, so a
`projectDir` whose absolute path happens to contain a blocklisted
segment (`/home/user/work/output/comp/`, `~/projects/dist/foo/`, etc.)
caused the filter to return false for every descendant — empty
compiled directory, broken render. Now matches relative-to-projectDir
segments via `path.relative()` + `split(sep)`. Switched from a regex
to a Set for clarity. Harness fixtures don't hit this because they
live under `tests/<name>/src/`, but adapters call `plan()` with
caller-supplied paths.
Dishonest type cast in regression-harness.ts (important):
- `as "mp4" | "mov" | "png-sequence"` claimed reachability for formats
that `validateMetadata` doesn't accept (the schema is `"mp4" | "webm"`,
and webm is rejected by `checkDistributedSupport`). Narrowed to
hardcoded `format: "mp4"` with a comment naming the metadata-schema
invariant that lets us do that.
Renamed `chunkVideoInjectorFactory` (nit):
- The variable was invoked once and never used again — "factory" implied
repeated calls. Inlined as a plain `videoInjector: BeforeCaptureHook | null`
ternary.
Replaced tautology test (nit):
- `expect(DISTRIBUTED_SIMULATED_MIN_PSNR_DB).toBe(10)` was a value-pin
over an exported constant. The invariant the JSDoc actually asserts is
"10 dB is below any real fixture's authored minPsnr"; if someone lands
a permissive fixture (minPsnr: 5), the value-pin doesn't catch it.
Replaced with a test that walks `tests/*/meta.json` and asserts every
authored `minPsnr` is ≥ the floor.
Validated in `docker:test --mode=distributed-simulated`:
font-variant-numeric, many-cuts, gsap-letters-render-compat,
style-1-prod, sub-composition-video — all PASSED.
Unit tests: 15/15 pass (new fixture-scan test included).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>