test(producer): add mp4 H.264 SDR distributed fixture

This commit is contained in:
James
2026-05-14 23:30:55 +00:00
parent 23dd1aa833
commit 2974bcb1a9
4 changed files with 200 additions and 28 deletions
+39 -28
View File
@@ -274,63 +274,74 @@ function discoverTestSuites(
throw new Error(`Tests directory not found: ${testsDir}`);
}
const entries = readdirSync(testsDir);
const suites: TestSuite[] = [];
for (const entry of entries) {
const dir = join(testsDir, entry);
if (!statSync(dir).isDirectory()) continue;
if (entry === "node_modules" || entry.startsWith(".")) continue;
// If filter is specified, skip non-matching tests
if (filterNames.length > 0 && !filterNames.includes(entry)) {
continue;
}
// Validate + push a single candidate fixture directory. Logs the reason
// and returns silently if the directory doesn't look like a fixture, so
// callers can blindly hand over every candidate.
const tryAddSuite = (id: string, dir: string): void => {
if (filterNames.length > 0 && !filterNames.includes(id)) return;
const srcDir = join(dir, "src");
const metaPath = join(dir, "meta.json");
// Validate structure
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
console.warn(`⚠️ Skipping ${entry}: missing src/ directory`);
continue;
console.warn(`⚠️ Skipping ${id}: missing src/ directory`);
return;
}
if (!existsSync(join(srcDir, "index.html"))) {
console.warn(`⚠️ Skipping ${entry}: missing src/index.html`);
continue;
console.warn(`⚠️ Skipping ${id}: missing src/index.html`);
return;
}
if (!existsSync(metaPath)) {
console.warn(`⚠️ Skipping ${entry}: missing meta.json`);
continue;
console.warn(`⚠️ Skipping ${id}: missing meta.json`);
return;
}
// Parse and validate meta.json
let meta: TestMetadata;
try {
const metaRaw = JSON.parse(readFileSync(metaPath, "utf-8"));
meta = validateMetadata(metaRaw);
} catch (error) {
console.warn(
`⚠️ Skipping ${entry}: invalid meta.json - ${error instanceof Error ? error.message : String(error)}`,
`⚠️ Skipping ${id}: invalid meta.json - ${error instanceof Error ? error.message : String(error)}`,
);
continue;
return;
}
// Skip tests with excluded tags
if (excludeTags.length > 0 && meta.tags.some((t) => excludeTags.includes(t))) {
logPretty(
`Skipping ${entry}: excluded by tags [${meta.tags.filter((t) => excludeTags.includes(t)).join(", ")}]`,
`Skipping ${id}: excluded by tags [${meta.tags.filter((t) => excludeTags.includes(t)).join(", ")}]`,
"⏭️",
);
return;
}
suites.push({ id, dir, srcDir, meta });
};
for (const entry of readdirSync(testsDir)) {
const dir = join(testsDir, entry);
if (!statSync(dir).isDirectory()) continue;
if (entry === "node_modules" || entry.startsWith(".")) continue;
// `tests/distributed/<name>/` is the home for fixtures authored
// specifically for the distributed pipeline (see tests/README.md and
// DISTRIBUTED-RENDERING-PLAN.md §10.2). Recurse one level deeper so
// each `<name>` becomes a first-class fixture ID (`mp4-h264-sdr`,
// `mov-prores`, …) the user can target on the CLI without their
// namespace prefix.
if (entry === "distributed") {
for (const sub of readdirSync(dir)) {
const subDir = join(dir, sub);
if (!statSync(subDir).isDirectory()) continue;
if (sub === "node_modules" || sub.startsWith(".")) continue;
tryAddSuite(sub, subDir);
}
continue;
}
suites.push({
id: entry,
dir,
srcDir,
meta,
});
tryAddSuite(entry, dir);
}
return suites;
+24
View File
@@ -148,6 +148,30 @@ exercises one of:
See `DISTRIBUTED-RENDERING-PLAN.md` §10.2 for the equivalence axes each
distributed fixture covers.
### 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.chunkSize` is **required** — pick a value that yields
N≥2 chunks for your fixture's frame count (e.g. 60 frames at
`chunkSize: 15` produces 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>` (no `distributed/`
prefix). `bun run --cwd packages/producer docker:test mp4-h264-sdr`
works the same as for a top-level fixture.
- The `distributed` tag 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-simulated` is validated
against the same baseline.
## Tags
Common `tags` values control which fixtures the default `bun test`
@@ -0,0 +1,16 @@
{
"name": "Distributed: mp4 H.264 SDR",
"description": "60-frame composition (2s @ 30fps) with text, a crossfade transition, and a small inline-SVG image. renderConfig.chunkSize=15 produces exactly N=4 chunks, exercising libx264's closed-GOP + concat-copy contract end-to-end.",
"tags": ["distributed", "mp4", "h264", "sdr"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0.9,
"maxAudioLagWindows": 120,
"renderConfig": {
"fps": 30,
"chunkSize": 15
}
}
@@ -0,0 +1,121 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>mp4 H.264 SDR distributed fixture</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap");
body,
html {
margin: 0;
padding: 0;
width: 640px;
height: 360px;
background: #0f172a;
overflow: hidden;
font-family: "Space Mono", monospace;
}
#main-comp {
position: relative;
width: 640px;
height: 360px;
}
.stage {
position: absolute;
inset: 0;
}
.label {
position: absolute;
top: 22%;
left: 50%;
transform: translateX(-50%);
font-size: 18px;
letter-spacing: 4px;
color: #94a3b8;
text-transform: uppercase;
}
.title {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: "Space Mono", monospace;
font-size: 56px;
font-weight: 700;
color: #6366f1;
white-space: nowrap;
}
.icon {
position: absolute;
bottom: 18%;
left: 50%;
transform: translate(-50%, 0);
width: 48px;
height: 48px;
}
</style>
</head>
<body>
<div
id="main-comp"
data-composition-id="main-comp"
data-width="640"
data-height="360"
data-start="0"
data-duration="2"
>
<div class="stage" id="stage-a">
<div class="label">CHUNK</div>
<div class="title" id="title-a">PHASE&nbsp;ONE</div>
</div>
<div class="stage" id="stage-b" style="opacity: 0">
<div class="label">CHUNK</div>
<div class="title" id="title-b">PHASE&nbsp;TWO</div>
</div>
<!-- Small inline SVG icon; no external image fetch required. -->
<svg class="icon" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" id="icon">
<circle cx="24" cy="24" r="18" fill="none" stroke="#6366f1" stroke-width="4" />
<circle cx="24" cy="24" r="6" fill="#6366f1" />
</svg>
<!--
No audio element on purpose: AAC frame quantization pads a 2-second
silent track past 2.0s of container time, which extends format.duration
past nb_frames / fps and trips the harness PSNR sampler at the very
last checkpoint. The chunk-boundary contracts this fixture pins are
video-only; omitting audio keeps container duration == 2.0s exactly.
-->
</div>
<script>
// Build a single timeline pinned to the composition's wall-clock so
// every frame is fully determined by the seek position. Chunk
// boundaries at frames {15, 30, 45} sit inside the crossfade window
// (frames 27-33 = 0.9s-1.1s) and the icon rotation — both of which
// therefore exercise per-chunk state continuity.
const tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main-comp"] = tl;
const stageA = document.getElementById("stage-a");
const stageB = document.getElementById("stage-b");
const icon = document.getElementById("icon");
// Crossfade A → B straddling the frame-30 chunk seam.
tl.to(stageA, { opacity: 0, duration: 0.2, ease: "none" }, 0.9);
tl.to(stageB, { opacity: 1, duration: 0.2, ease: "none" }, 0.9);
// Continuous icon rotation — the absolute angle at any time must match
// across chunk boundaries, so a state-keeping regression in the engine's
// virtual clock would show up as a rotation discontinuity at frame 15,
// 30, or 45.
tl.to(icon, { rotation: 360, duration: 2, ease: "none" }, 0);
</script>
</body>
</html>