* fix(producer): mix audio into a container that can record encoder delay Every rendered composition's audio landed 1024 samples (21.33 ms at 48 kHz) after its authored `data-start`, against a frame-accurate video track. The mix is AAC-encoded, and AAC encoders emit ~1024 priming samples. The mix was written to a raw ADTS `.aac` file, which has nowhere to record that delay, so it decoded as real leading silence and every stage downstream preserved it faithfully. Measuring each intermediate localises it precisely: the source WAV is exact, the mixer's own output is already 21.33 ms late, and the pad/trim and mux stages inherit it unchanged. The filter graph itself is correct - run by hand to PCM it lands on the authored start. Switch the artifact to an MP4-family container, which stores the delay as an edit list that decoders strip. Same codec, same bitrate, so no size or quality change. The filename is a contract shared by three consumers - the mux input, the distributed plan artifact, and the PNG-sequence sidecar handed to users for NLE ingest - and its extension is what selects the muxer. Give it one owner in the engine rather than five literals, so those consumers cannot drift onto different containers. Note for reviewers: this renames the distributed plan's audio artifact, which is an on-disk contract between the plan writer and the assembler. Both move together here, but a plan written by an older build would not be found by a newer assembler. Flagging in case that mixed-version window matters for how these are deployed. * fix(cloud): read the plan audio artifact name from the producer contract The aws-lambda and gcp-cloud-run adapters each restated the plan's audio filename in five places, so renaming it in the producer left them looking for a file that is no longer written. CI caught it: the gcp dispatch test asserting a plan has no audio artifact started seeing one. Export the name from `@hyperframes/producer/distributed` and consume it in both adapters. This is the same failure the constant exists to prevent, one package boundary further out: a literal that drifts from the writer's is a silently missing audio track rather than a loud error, because both call sites only ever ask whether the file exists. * fix(cloud): accept a legacy plan's audio artifact name for one release Review raised a rolling-deploy window I had flagged but left undecided: `plan` and `assemble` are separate invocations bridged by object storage, so a pre-rollout planner can be paired with a post-rollout assembler. Both readers locate the artifact by existence alone, which makes that pairing a silently muted video rather than an error. That is reachable enough to be worth two lines, so reads now accept the old name while writes only ever emit the new one. Give the fallback one owner (`resolvePlanAudioPath` / `isPlanAudioArtifactPath`) rather than four call sites, marked for deletion one release out. Also fixes a hole in the first pass of this: the plan-v2 materializer matched either name but then joined the CURRENT one, so a legacy plan resolved to a path that was never written. It now joins the artifact's own name. Review nits in the same pass: correct the pad-branch docstring, which still described a concat-copy shape the pad branch stopped using when it moved to apad + re-encode, and fix the Windows fixture's stale `.aac` output extension so it cannot model a shape that reintroduces the priming delay. * test(producer): rebake the missing-host-comp-id golden without the audio delay The pinned reference was rendered before this branch, so it carries the 1024 sample encoder-priming delay in its audio. With the delay gone the correct audio now sits ahead of the reference and the harness's envelope correlation drops below its floor. Cross-correlating the old and new references at native 48 kHz gives a lag of exactly 1024 samples (21.33 ms) at a correlation of 0.99985: same audio, moved by exactly the amount this branch removes. Regenerated inside the CI container (Dockerfile.test, ffmpeg 5.1.9) rather than natively, so the reference matches the encoder CI will compare against - the container reproduced CI's failure to the digit (correlation 0.3938764027803616, lagWindows -12) before the rebake and passes at correlation 1.0 after it. Note for archaeology: the new reference is also 3 dB louder than the old one. That gap is not from this branch - `main` and this branch render the fixture at the same level - it is pre-existing drift the reference had accumulated, which a scale-invariant correlator could never see. The rebake absorbs it. Only output.mp4 is updated. `--update` also rewrites compiled.html, but that diff is embedded-font churn with no bearing on the comparison, which reports "Failed at compilation: 0" either way. * test(producer): rebake the variables-prod golden without the audio delay Same cause as the missing-host-comp-id rebake, caught by shard-8 once the earlier shard stopped failing and the rest of the matrix could run: this reference also carries the encoder-priming delay this branch removes. Reproduced in the CI container to the digit (correlation 0.42704173048439215, lagWindows -12), rebaked there, and it now passes at correlation 1.0. Worth recording: the shift here is 2048 samples (42.67 ms) at correlation 0.99983, exactly twice the 1024 of the other fixture. The delay compounds once per un-compensated AAC generation, and this fixture's audio needs its duration normalized, so it takes the pad/trim branch's re-encode and picks up a second frame of priming on top of the mixer's. So the pre-fix error was not a fixed 21 ms - it grew with the number of times the audio was re-encoded. All nine shards ran in that CI round with only this one failing, so the matrix has now covered every fixture against this change.
Producer regression test fixtures
Each subdirectory under this folder is a regression fixture for the
HTML-to-video pipeline. The harness at
packages/producer/src/regression-harness.ts walks every subdirectory,
runs the composition, and PSNR-compares the rendered output against a
checked-in golden baseline.
Fixture layout
<fixture-name>/
├── meta.json # name, tags, PSNR threshold, renderConfig
├── src/
│ ├── index.html # composition entry point
│ └── assets/... # any locally-referenced media
└── output/
├── compiled.html # golden compiled HTML (validated as a snapshot)
└── output.mp4 # golden rendered video
meta.json is validated by validateMetadata in
src/regression-harness.ts. The required fields are:
name(string),description(string),tags(string[])minPsnr(number, dB)maxFrameFailures(integer)minAudioCorrelation(0..1),maxAudioLagWindows(integer ≥1)renderConfig.fps(integer like30or a rational string like"30000/1001")
Optional renderConfig fields:
format—"mp4"(default) or"webm"workers— integer ≥ 1hdr— boolean (defaultfalse)variables— JSON object of render-time variable overrideschunkSize— integer ≥ 1 (used by--mode=distributed-simulated)maxParallelChunks— integer ≥ 1 (used by--mode=distributed-simulated)
Generating / updating a baseline
Always inside Docker. Host Chrome / FFmpeg versions drift across distros, so a baseline captured on the host won't match the bytes CI renders.
# From the repo root.
docker build -t hyperframes-producer:test -f Dockerfile.test .
# Generate a baseline (single fixture):
bun run --cwd packages/producer docker:test:update <fixture-name>
# Generate all baselines (rarely needed):
bun run --cwd packages/producer docker:test:update
The --update flag writes output/compiled.html and output/output.mp4
from the current render. Without --update, the harness compares against
those baselines.
Running the harness locally
# Run every fixture (parallel, in-process mode — the default).
bun run --cwd packages/producer docker:test
# Run a single fixture:
bun run --cwd packages/producer docker:test font-variant-numeric
# Run sequentially (lower memory):
bun run --cwd packages/producer docker:test -- --sequential
Harness modes
--mode=<value> chooses which render path the harness exercises:
| Mode | What it calls | Use for |
|---|---|---|
in-process (default) |
executeRenderJob |
Day-to-day baselines. This is the same path the hyperframes render CLI takes, and it is what produced every existing output/output.mp4. |
distributed-simulated |
plan() → renderChunk() × N → assemble() from @hyperframes/producer/distributed |
Validates the distributed pipeline against the in-process baseline. No Temporal or Lambda involvement — the controller and chunk worker are both this process. |
--mode=distributed-simulated
bun run --cwd packages/producer docker:test -- --mode=distributed-simulated
bun run --cwd packages/producer docker:test font-variant-numeric -- --mode=distributed-simulated
The distributed pipeline cannot run every fixture. Fixtures that fail any of these gates are skipped with a clear log line (and counted as passing in the summary):
fps.den !== 1— distributed mode is integer-fps only (no NTSC).fps.num ∉ {24, 30, 60}— closed set perDistributedRenderConfig.format === "webm"—plan()refuses webm.hdr === true— distributed mode is SDR-only at v1.
Both modes use the fixture's authored minPsnr as the per-test
threshold — distributed must clear the same quality bar in-process
clears against the same frozen baseline. (Internal contract: distributed
vs in-process renders of the same fixture should clear 50 dB PSNR
against each other within the same Docker image. Against the frozen
committed baseline, neither mode reaches that consistently due to
shared encoder/JPEG-capture jitter — that's why the fixture's authored
threshold gates here, not the 50 dB contract value.) An absolute 10 dB
pathology floor catches fully-black-output regressions when a fixture
authors a permissive threshold. A distributed failure at the fixture's
own threshold means the distributed pipeline has drifted — file an
issue rather than relaxing the fixture.
--update is incompatible with --mode=distributed-simulated: the
in-process renderer is the source of truth for baselines, and the
distributed mode's job is to verify the contract against the same
baseline.
Validating PR 4.1 (the harness mode itself)
The smallest fixtures (font-variant-numeric, many-cuts) are sufficient
to verify the mode plumbing end to end:
docker build -t hyperframes-producer:test -f Dockerfile.test .
# In-process: existing behavior, unchanged.
bun run --cwd packages/producer docker:test font-variant-numeric
bun run --cwd packages/producer docker:test many-cuts
# Distributed-simulated: same baselines, distributed pipeline.
bun run --cwd packages/producer docker:test font-variant-numeric -- --mode=distributed-simulated
bun run --cwd packages/producer docker:test many-cuts -- --mode=distributed-simulated
Both modes must pass at each fixture's authored minPsnr against the
existing baseline. If --mode=distributed-simulated fails where
--mode=in-process passes, the distributed primitive has a regression —
file an issue rather than relaxing the fixture's threshold.
Distributed-only fixtures
Fixtures under tests/distributed/<name>/ are authored specifically for
the distributed pipeline. They follow the same meta.json schema as the
top-level fixtures, but they always set chunkSize / maxParallelChunks
so a plan() over the fixture produces N>1 chunks. Each fixture
exercises one of:
- per-format chunk-boundary correctness (mp4 H.264, mp4 H.265, ProRes, png-sequence)
- per-adapter chunk-seam state preservation (GSAP, Anime.js, Three.js, Lottie, CSS, WAAPI)
Each distributed fixture covers one or more equivalence axes — see the
meta.json description field for what a given fixture is locking in.
Fixture pattern (4.2 onward)
Each tests/distributed/<name>/ fixture has the same structure as a
top-level fixture (meta.json + src/index.html + output/output.mp4).
Differences worth knowing:
renderConfig.chunkSizeis required — pick a value that yields N≥2 chunks for your fixture's frame count (e.g. 60 frames atchunkSize: 15produces N=4). Without this the fixture renders in a single chunk and never exercises the seam.- The fixture's ID on the CLI is just
<name>(nodistributed/prefix).bun run --cwd packages/producer docker:test mp4-h264-sdrworks the same as for a top-level fixture. - The
distributedtag is informational — it doesn't gate any tag-based filter today. Add it so the fixture is easy to find by tag. - The composition should stress state continuity across the chunk seams: an animation crossing a seam, a counter, a rotation. A fully-static composition would pass even if chunk-boundary state was broken.
- Baselines must be generated inside Docker — see the section above.
The baseline is rendered by the in-process renderer (the source of
truth for golden output);
--mode=distributed-simulatedis validated against the same baseline.
Tags
Common tags values control which fixtures the default bun test
invocation runs. --exclude-tags transparency (the default for
bun test) skips webm/png-sequence alpha fixtures that need a working
chrome-headless-shell alpha pipeline.