mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
test(producer): add mp4 H.264 SDR distributed fixture
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 ONE</div>
|
||||
</div>
|
||||
<div class="stage" id="stage-b" style="opacity: 0">
|
||||
<div class="label">CHUNK</div>
|
||||
<div class="title" id="title-b">PHASE 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>
|
||||
Reference in New Issue
Block a user