Skip to content

Commit 2974bcb

Browse files
committed
test(producer): add mp4 H.264 SDR distributed fixture
1 parent 23dd1aa commit 2974bcb

4 files changed

Lines changed: 200 additions & 28 deletions

File tree

packages/producer/src/regression-harness.ts

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -274,63 +274,74 @@ function discoverTestSuites(
274274
throw new Error(`Tests directory not found: ${testsDir}`);
275275
}
276276

277-
const entries = readdirSync(testsDir);
278277
const suites: TestSuite[] = [];
279278

280-
for (const entry of entries) {
281-
const dir = join(testsDir, entry);
282-
if (!statSync(dir).isDirectory()) continue;
283-
if (entry === "node_modules" || entry.startsWith(".")) continue;
284-
285-
// If filter is specified, skip non-matching tests
286-
if (filterNames.length > 0 && !filterNames.includes(entry)) {
287-
continue;
288-
}
279+
// Validate + push a single candidate fixture directory. Logs the reason
280+
// and returns silently if the directory doesn't look like a fixture, so
281+
// callers can blindly hand over every candidate.
282+
const tryAddSuite = (id: string, dir: string): void => {
283+
if (filterNames.length > 0 && !filterNames.includes(id)) return;
289284

290285
const srcDir = join(dir, "src");
291286
const metaPath = join(dir, "meta.json");
292287

293-
// Validate structure
294288
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
295-
console.warn(`⚠️ Skipping ${entry}: missing src/ directory`);
296-
continue;
289+
console.warn(`⚠️ Skipping ${id}: missing src/ directory`);
290+
return;
297291
}
298292
if (!existsSync(join(srcDir, "index.html"))) {
299-
console.warn(`⚠️ Skipping ${entry}: missing src/index.html`);
300-
continue;
293+
console.warn(`⚠️ Skipping ${id}: missing src/index.html`);
294+
return;
301295
}
302296
if (!existsSync(metaPath)) {
303-
console.warn(`⚠️ Skipping ${entry}: missing meta.json`);
304-
continue;
297+
console.warn(`⚠️ Skipping ${id}: missing meta.json`);
298+
return;
305299
}
306300

307-
// Parse and validate meta.json
308301
let meta: TestMetadata;
309302
try {
310303
const metaRaw = JSON.parse(readFileSync(metaPath, "utf-8"));
311304
meta = validateMetadata(metaRaw);
312305
} catch (error) {
313306
console.warn(
314-
`⚠️ Skipping ${entry}: invalid meta.json - ${error instanceof Error ? error.message : String(error)}`,
307+
`⚠️ Skipping ${id}: invalid meta.json - ${error instanceof Error ? error.message : String(error)}`,
315308
);
316-
continue;
309+
return;
317310
}
318311

319-
// Skip tests with excluded tags
320312
if (excludeTags.length > 0 && meta.tags.some((t) => excludeTags.includes(t))) {
321313
logPretty(
322-
`Skipping ${entry}: excluded by tags [${meta.tags.filter((t) => excludeTags.includes(t)).join(", ")}]`,
314+
`Skipping ${id}: excluded by tags [${meta.tags.filter((t) => excludeTags.includes(t)).join(", ")}]`,
323315
"⏭️",
324316
);
317+
return;
318+
}
319+
320+
suites.push({ id, dir, srcDir, meta });
321+
};
322+
323+
for (const entry of readdirSync(testsDir)) {
324+
const dir = join(testsDir, entry);
325+
if (!statSync(dir).isDirectory()) continue;
326+
if (entry === "node_modules" || entry.startsWith(".")) continue;
327+
328+
// `tests/distributed/<name>/` is the home for fixtures authored
329+
// specifically for the distributed pipeline (see tests/README.md and
330+
// DISTRIBUTED-RENDERING-PLAN.md §10.2). Recurse one level deeper so
331+
// each `<name>` becomes a first-class fixture ID (`mp4-h264-sdr`,
332+
// `mov-prores`, …) the user can target on the CLI without their
333+
// namespace prefix.
334+
if (entry === "distributed") {
335+
for (const sub of readdirSync(dir)) {
336+
const subDir = join(dir, sub);
337+
if (!statSync(subDir).isDirectory()) continue;
338+
if (sub === "node_modules" || sub.startsWith(".")) continue;
339+
tryAddSuite(sub, subDir);
340+
}
325341
continue;
326342
}
327343

328-
suites.push({
329-
id: entry,
330-
dir,
331-
srcDir,
332-
meta,
333-
});
344+
tryAddSuite(entry, dir);
334345
}
335346

336347
return suites;

packages/producer/tests/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,30 @@ exercises one of:
148148
See `DISTRIBUTED-RENDERING-PLAN.md` §10.2 for the equivalence axes each
149149
distributed fixture covers.
150150

151+
### Fixture pattern (4.2 onward)
152+
153+
Each `tests/distributed/<name>/` fixture has the same structure as a
154+
top-level fixture (`meta.json` + `src/index.html` + `output/output.mp4`).
155+
Differences worth knowing:
156+
157+
- `renderConfig.chunkSize` is **required** — pick a value that yields
158+
N≥2 chunks for your fixture's frame count (e.g. 60 frames at
159+
`chunkSize: 15` produces N=4). Without this the fixture renders in a
160+
single chunk and never exercises the seam.
161+
- The fixture's ID on the CLI is just `<name>` (no `distributed/`
162+
prefix). `bun run --cwd packages/producer docker:test mp4-h264-sdr`
163+
works the same as for a top-level fixture.
164+
- The `distributed` tag is informational — it doesn't gate any tag-based
165+
filter today. Add it so the fixture is easy to find by tag.
166+
- The composition should stress *state continuity* across the chunk
167+
seams: an animation crossing a seam, a counter, a rotation. A
168+
fully-static composition would pass even if chunk-boundary state was
169+
broken.
170+
- Baselines must be generated inside Docker — see the section above.
171+
The baseline is rendered by the in-process renderer (the source of
172+
truth for golden output); `--mode=distributed-simulated` is validated
173+
against the same baseline.
174+
151175
## Tags
152176

153177
Common `tags` values control which fixtures the default `bun test`
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "Distributed: mp4 H.264 SDR",
3+
"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.",
4+
"tags": ["distributed", "mp4", "h264", "sdr"],
5+
6+
"minPsnr": 30,
7+
"maxFrameFailures": 0,
8+
9+
"minAudioCorrelation": 0.9,
10+
"maxAudioLagWindows": 120,
11+
12+
"renderConfig": {
13+
"fps": 30,
14+
"chunkSize": 15
15+
}
16+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
6+
<title>mp4 H.264 SDR distributed fixture</title>
7+
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
8+
<style>
9+
@import url("https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap");
10+
11+
body,
12+
html {
13+
margin: 0;
14+
padding: 0;
15+
width: 640px;
16+
height: 360px;
17+
background: #0f172a;
18+
overflow: hidden;
19+
font-family: "Space Mono", monospace;
20+
}
21+
22+
#main-comp {
23+
position: relative;
24+
width: 640px;
25+
height: 360px;
26+
}
27+
28+
.stage {
29+
position: absolute;
30+
inset: 0;
31+
}
32+
33+
.label {
34+
position: absolute;
35+
top: 22%;
36+
left: 50%;
37+
transform: translateX(-50%);
38+
font-size: 18px;
39+
letter-spacing: 4px;
40+
color: #94a3b8;
41+
text-transform: uppercase;
42+
}
43+
44+
.title {
45+
position: absolute;
46+
top: 50%;
47+
left: 50%;
48+
transform: translate(-50%, -50%);
49+
font-family: "Space Mono", monospace;
50+
font-size: 56px;
51+
font-weight: 700;
52+
color: #6366f1;
53+
white-space: nowrap;
54+
}
55+
56+
.icon {
57+
position: absolute;
58+
bottom: 18%;
59+
left: 50%;
60+
transform: translate(-50%, 0);
61+
width: 48px;
62+
height: 48px;
63+
}
64+
</style>
65+
</head>
66+
<body>
67+
<div
68+
id="main-comp"
69+
data-composition-id="main-comp"
70+
data-width="640"
71+
data-height="360"
72+
data-start="0"
73+
data-duration="2"
74+
>
75+
<div class="stage" id="stage-a">
76+
<div class="label">CHUNK</div>
77+
<div class="title" id="title-a">PHASE&nbsp;ONE</div>
78+
</div>
79+
<div class="stage" id="stage-b" style="opacity: 0">
80+
<div class="label">CHUNK</div>
81+
<div class="title" id="title-b">PHASE&nbsp;TWO</div>
82+
</div>
83+
<!-- Small inline SVG icon; no external image fetch required. -->
84+
<svg class="icon" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" id="icon">
85+
<circle cx="24" cy="24" r="18" fill="none" stroke="#6366f1" stroke-width="4" />
86+
<circle cx="24" cy="24" r="6" fill="#6366f1" />
87+
</svg>
88+
<!--
89+
No audio element on purpose: AAC frame quantization pads a 2-second
90+
silent track past 2.0s of container time, which extends format.duration
91+
past nb_frames / fps and trips the harness PSNR sampler at the very
92+
last checkpoint. The chunk-boundary contracts this fixture pins are
93+
video-only; omitting audio keeps container duration == 2.0s exactly.
94+
-->
95+
</div>
96+
97+
<script>
98+
// Build a single timeline pinned to the composition's wall-clock so
99+
// every frame is fully determined by the seek position. Chunk
100+
// boundaries at frames {15, 30, 45} sit inside the crossfade window
101+
// (frames 27-33 = 0.9s-1.1s) and the icon rotation — both of which
102+
// therefore exercise per-chunk state continuity.
103+
const tl = gsap.timeline({ paused: true });
104+
window.__timelines = window.__timelines || {};
105+
window.__timelines["main-comp"] = tl;
106+
107+
const stageA = document.getElementById("stage-a");
108+
const stageB = document.getElementById("stage-b");
109+
const icon = document.getElementById("icon");
110+
111+
// Crossfade A → B straddling the frame-30 chunk seam.
112+
tl.to(stageA, { opacity: 0, duration: 0.2, ease: "none" }, 0.9);
113+
tl.to(stageB, { opacity: 1, duration: 0.2, ease: "none" }, 0.9);
114+
// Continuous icon rotation — the absolute angle at any time must match
115+
// across chunk boundaries, so a state-keeping regression in the engine's
116+
// virtual clock would show up as a rotation discontinuity at frame 15,
117+
// 30, or 45.
118+
tl.to(icon, { rotation: 360, duration: 2, ease: "none" }, 0);
119+
</script>
120+
</body>
121+
</html>

0 commit comments

Comments
 (0)