mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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}`);
|
throw new Error(`Tests directory not found: ${testsDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = readdirSync(testsDir);
|
|
||||||
const suites: TestSuite[] = [];
|
const suites: TestSuite[] = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
// Validate + push a single candidate fixture directory. Logs the reason
|
||||||
const dir = join(testsDir, entry);
|
// and returns silently if the directory doesn't look like a fixture, so
|
||||||
if (!statSync(dir).isDirectory()) continue;
|
// callers can blindly hand over every candidate.
|
||||||
if (entry === "node_modules" || entry.startsWith(".")) continue;
|
const tryAddSuite = (id: string, dir: string): void => {
|
||||||
|
if (filterNames.length > 0 && !filterNames.includes(id)) return;
|
||||||
// If filter is specified, skip non-matching tests
|
|
||||||
if (filterNames.length > 0 && !filterNames.includes(entry)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const srcDir = join(dir, "src");
|
const srcDir = join(dir, "src");
|
||||||
const metaPath = join(dir, "meta.json");
|
const metaPath = join(dir, "meta.json");
|
||||||
|
|
||||||
// Validate structure
|
|
||||||
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
|
if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
|
||||||
console.warn(`⚠️ Skipping ${entry}: missing src/ directory`);
|
console.warn(`⚠️ Skipping ${id}: missing src/ directory`);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
if (!existsSync(join(srcDir, "index.html"))) {
|
if (!existsSync(join(srcDir, "index.html"))) {
|
||||||
console.warn(`⚠️ Skipping ${entry}: missing src/index.html`);
|
console.warn(`⚠️ Skipping ${id}: missing src/index.html`);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
if (!existsSync(metaPath)) {
|
if (!existsSync(metaPath)) {
|
||||||
console.warn(`⚠️ Skipping ${entry}: missing meta.json`);
|
console.warn(`⚠️ Skipping ${id}: missing meta.json`);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate meta.json
|
|
||||||
let meta: TestMetadata;
|
let meta: TestMetadata;
|
||||||
try {
|
try {
|
||||||
const metaRaw = JSON.parse(readFileSync(metaPath, "utf-8"));
|
const metaRaw = JSON.parse(readFileSync(metaPath, "utf-8"));
|
||||||
meta = validateMetadata(metaRaw);
|
meta = validateMetadata(metaRaw);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
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))) {
|
if (excludeTags.length > 0 && meta.tags.some((t) => excludeTags.includes(t))) {
|
||||||
logPretty(
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
suites.push({
|
tryAddSuite(entry, dir);
|
||||||
id: entry,
|
|
||||||
dir,
|
|
||||||
srcDir,
|
|
||||||
meta,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return suites;
|
return suites;
|
||||||
|
|||||||
@@ -148,6 +148,30 @@ exercises one of:
|
|||||||
See `DISTRIBUTED-RENDERING-PLAN.md` §10.2 for the equivalence axes each
|
See `DISTRIBUTED-RENDERING-PLAN.md` §10.2 for the equivalence axes each
|
||||||
distributed fixture covers.
|
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
|
## Tags
|
||||||
|
|
||||||
Common `tags` values control which fixtures the default `bun test`
|
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