* 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.
Adds a Blocks tab to the Studio left sidebar with the full 78-item registry
catalog (58 blocks + 20 components). Users can browse by category, search by
title/description, preview CDN-hosted poster thumbnails with video-on-hover,
and install items on-demand with one click or drag-to-timeline.
Core changes:
- BlockCategory type + resolveBlockCategory() for 7 categories (Captions, VFX,
Transitions, Effects, Social, Data, Scenes)
- Registry API routes: GET /api/registry/blocks (catalog) + POST install
- StudioApiAdapter extended with listRegistryCatalog + installRegistryBlock
- Vite adapter reads from disk; CLI adapter fetches from GitHub (24h cache)
- BlockParam interface + params on 6 blocks for future parameter controls
Studio UI:
- 4th sidebar tab "Blocks" with responsive grid, category pills, search bar
- BlockCard: CDN poster thumbnail, video autoplay on hover, duration + WebGL badges
- On-demand install: blocks append as sub-compositions on timeline; components
overlay at start=0 spanning full duration with transparent background patching
- TIMELINE_BLOCK_MIME drag-and-drop to timeline
- BlockParamsPanel (Phase 3 scaffold) auto-opens for parameterized blocks
Registry manifests:
- All 58 blocks backfilled with preview: { video, poster } CDN URLs
- All 20 components normalized to object format + poster URLs added
- 6 blocks annotated with params (Liquid Glass/Background, Portal, Chart,
Logo Outro, Magnetic)
- flowchart-vertical preview generated and uploaded to CDN
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).
## Summary
- Regenerate golden baselines for 7 regression tests that were deterministically failing due to stale baselines from before the recent renderer fixes
- Root cause: renderer changes (revert flattenInnerRoot, late-bind polling removal, conditional rebind, sub-comp timeline readiness polling) changed visual output, but baselines were never regenerated
- CI's `Detect changes` job was skipping shards on non-engine commits, masking the failures — making it look like flaky tests when in reality they failed every time shards ran
## Tests regenerated (all in Docker with pinned Chrome 148.0.7778.167)
| Test | Previously failed frames | Baseline age |
|---|---|---|
| `gsap-letters-render-compat` | 86/100 | old |
| `typegpu-adapter` | 76/100 | May 13 |
| `style-7-prod` | 60/100 | PR #368 (very old) |
| `style-15-prod` | 59/100 | May 17 (pre-renderer-fixes) |
| `style-18-prod` | 19/100 | old |
| `style-3-prod` | 7/100 | May 17 (pre-renderer-fixes) |
| `style-8-prod` | 1/100 | borderline |
## Test plan
- [x] All 7 tests pass locally in Docker with 0 failed frames
- [x] Verified deterministic — ran style-7-prod twice, same result both times
- [x] All 8 CI regression shards green
- [x] Other tests (style-1, 2, 4, 5, 6, 9, 10, 11, 12, 13, 16, 17, overlay-montage, sub-composition-video, vignelli-stacking, etc.) still pass
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Missed in the initial batch — 86/100 frames were failing. Regenerated
in Docker with the same pinned Chrome build. Now passes with 0 failures.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- [blocker] When playerAdapter is null (GSAP-only runtimes with no
win.__player), the fallback path now uses the best available timeline
adapter instead of returning null. Track timelineAdapter across the
__timeline and __timelines paths, then use it as the base for
createStaticSeekPlaybackAdapter.
- [nit] Remove dead baseAdapter alias — use bestAdapter directly.
- [tests] Add 4 tests: readTimelineDurationFromDocument with
data-hf-authored-duration fallback, createStaticSeekPlaybackAdapter
with seek-only adapter (no renderSeek), and pause lifecycle.
getAdapter() returned the runtime player or GSAP timeline adapter directly
when its duration was > 0, even when the document's timeline (computed from
sub-composition data-start + data-hf-authored-duration attributes) extended
beyond that duration. This capped the seek slider, seek clamping, and
sub-composition visibility at the adapter's shorter value.
Now each adapter path checks whether the document duration exceeds the
adapter's own duration. When it does, the adapter falls through to
createStaticSeekPlaybackAdapter which wraps the runtime player with the
correct effective duration, allowing seeking and preview across the full
timeline range.
When the runtime player's clock duration is smaller than the effective
timeline (computed from data-start + data-hf-authored-duration on
sub-composition elements), the seek is clamped too early and sub-compositions
beyond the clock duration are invisible.
Detect this mismatch in getAdapter() and pad the root GSAP timeline to the
document duration, then force a timeline rebind so the clock updates.
The seek function clamped to adapter.getDuration() which only knows the
root composition's authored duration. Appended timeline elements extend
beyond this range. Compute the effective max from both the adapter duration
and the store's element boundaries so scrubbing reaches the full timeline.
- Move Timing + Media sections above Layout in the Design panel
- Remove LayerTree from Design panel (redundant with Layers tab)
- Replace Rate and Media Start with sliders matching Volume's UX
- Replace Position DetailField with SelectField to match Fit height
- Remove Poster field (not useful for HyperFrames compositions)
- Show absolute filesystem path for Source (resolves symlinks)
- Add Copy button for source path with checkmark feedback
- Preserve element selection on undo/redo instead of clearing it
Move the 7-step production pipeline (Capture → Design → Script → Storyboard
→ VO + Timing → Build → Validate) into its own dedicated guide so it serves
any Hyperframes project, not just website-to-video. Expand each step with
file contents, project layout, gates, and iteration patterns. Reference the
new page from website-to-video, quickstart, prompting, and launch-videos.
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>
Replace the useEffect that pushed effectiveTimelineDuration into the player
store with an inline derived selector in PlayerControls. The selector computes
Math.max(duration, maxElementEnd) directly from store state, avoiding the
effect-based sync anti-pattern entirely.
Adds a new Media section to the Design panel that appears when a <video>
or <audio> element is selected. Controls include volume (slider),
playback rate, media start offset, loop/muted toggles, and for video:
object-fit, object-position, poster, and has-audio-track toggle.
Extends the source patcher with an "html-attribute" operation type for
native HTML attributes (loop, muted, poster) that don't use the data-
prefix. Adds coalesceKey to attribute commits so rapid slider/scrub
edits merge into a single undo entry.
The seek slider read duration from the player store, which was set from the
iframe adapter's getDuration() — only aware of the root composition's authored
data-duration. Appended sub-compositions (via Blocks panel) extend the timeline
but the slider stayed capped at the original duration.
Sync effectiveTimelineDuration (which accounts for all timeline elements) into
the player store, and prevent adapter callbacks from overwriting a larger
effective duration back down to the authored value.
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
- Split pan clamping: clampPreviewPan (drag/wheel-pan) stays narrow
(Math.max(0,...) — content pins to center when smaller than viewport).
New clampPreviewPanForZoom (Math.abs) gives the wide range only to
cursor-anchored zoom, preventing middle-mouse drag from pushing content
off-screen at low zoom levels.
- Pin transform-origin invariant: comment on the stage div noting that
resolvePreviewWheelZoom cursor math depends on center-center pivot.
New test verifies a non-center cursor keeps the same content-space
point fixed across a zoom step.
- Remove dead Math.abs(oldScale) > 1e-6 guard — oldScale >= 0.25 always
(clampPreviewZoomPercent floors at MIN_PREVIEW_ZOOM_PERCENT = 25).
- Skip setSettledZoom re-render when the value didn't change — uses a
functional updater that returns the previous state object when all
three fields match, avoiding a React re-render cascade through Player.
- Zoom anchors to cursor position instead of always zooming toward center.
The resolvePreviewWheelZoom function now accepts cursorX/cursorY (offset
from viewport center) and uses the standard zoom-to-point formula to
adjust pan so the content point under the cursor stays fixed.
- Add visible "Reset" button (bottom-right) showing current zoom % when
not at fit zoom. Driven by settledZoom state that updates after the
200ms settle debounce, so no re-renders during active zoom gestures.
- Fix border-expands-inward bug: scaleIframeToFit in the player now uses
offsetWidth/offsetHeight instead of getBoundingClientRect. The latter
returns values inflated by ancestor CSS zoom, causing double-scaling
that made the iframe appear smaller than its container.
- Fix zoom HUD appearing during pan: split applyZoom (shows HUD) from
applyPan (silent) so trackpad/middle-mouse panning no longer flashes
the zoom percentage overlay.
- Fix stale closure performance regression: replace stageSize in effect
dependency arrays with stageSizeRef pattern. The old deps caused wheel
and pointer handlers to re-register on every viewport resize.
- Widen pan clamp range (Math.abs instead of Math.max(0,...)) so content
can float within the viewport when zoomed below fit — required for
zoom-to-cursor to work correctly at any zoom level.
Closes#900
Rename component to caption-texture and bundle 6 popular textures
(lava, marble, metal, wood, concrete, rock). The texture is configurable
via the texture composition variable (default: lava).
Usage: hyperframes render --variables '{"texture":"marble-012"}'
Tested: lava, marble-012, metal-046-b all render correctly with
distinct visual patterns.
The missing_three_script rule only checked <script src> attributes
for Three.js. Now also detects:
- importmap entries defining "three"
- ES module import/from statements referencing "three"
Closes#931