test(producer): add chunk-boundary fixtures per first-party adapter

This commit is contained in:
James
2026-05-15 02:46:44 +00:00
committed by James Russo
parent 7bd4be13c1
commit 0b31465b2e
7 changed files with 683 additions and 0 deletions
@@ -0,0 +1,202 @@
/**
* Per-adapter chunk-boundary contract: rendering the same composition at
* chunkSize=N (single chunk, no seams) vs chunkSize=N/4 (four chunks, three
* seams at frames 15, 30, 45) MUST produce byte-identical *frames*. This
* is the strongest contract a distributed render can satisfy — anything
* weaker means the worker's seek-determinism leaks across chunk boundaries.
*
* Output format is png-sequence rather than mp4 because mp4 bitstreams
* encode keyframe placement directly: chunkSize=60 emits 1 IDR; chunkSize=15
* emits 4 IDRs at frames 0/15/30/45. Those are legitimately different bytes
* even when the captured pixels are identical. The png-sequence assemble
* path merges chunk frame directories with no re-encode, so per-frame
* byte equality round-trips a pixel-level contract.
*
* For each first-party adapter (GSAP, Anime.js, Three.js, Lottie, CSS,
* WAAPI), `tests/distributed/<adapter>-boundary/src/index.html` is a
* 60-frame composition that drives the adapter through its registered seek
* hook. The test:
*
* 1. plan() + renderChunk() × N + assemble() at chunkSize=60 → N=1 chunk.
* 2. Same at chunkSize=15 → N=4 chunks.
* 3. Per-frame `Buffer.equals` across the two output frame directories.
*
* Fixtures with no checked-in baseline aren't compared by the regression
* harness — they're driven from here via `bun test`. CI exercises them
* through the same `bun test` step inside `Dockerfile.test`.
*
* Soft-skip behavior matches `renderChunk.test.ts`: if the host's
* `chrome-headless-shell` can't render (no SwiftShader, missing GL stack),
* the test logs a warning and returns. The Docker harness covers the real
* contract against a known-good image.
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { assemble } from "./assemble.js";
import { plan } from "./plan.js";
import { renderChunk } from "./renderChunk.js";
const HOST_CHROME_FAILURE_PATTERNS =
/chrome:\/\/gpu|BROWSER_GPU_NOT_SOFTWARE|SwiftShader|HeadlessExperimental\.beginFrame|Target closed/i;
// Per-adapter fixture directories under `packages/producer/tests/distributed/`.
// Each must hold `src/index.html`; this test owns the planning + render +
// assemble pipeline so no `output/` baseline is required.
const ADAPTERS = ["gsap", "anime", "three", "lottie", "css", "waapi"] as const;
let runRoot: string;
let testsDistributedDir: string;
beforeAll(() => {
runRoot = mkdtempSync(join(tmpdir(), "hf-chunk-boundary-test-"));
// `__dirname`-equivalent in ESM.
const moduleDir = dirname(fileURLToPath(import.meta.url));
// packages/producer/src/services/distributed/ → packages/producer/tests/distributed/
testsDistributedDir = resolve(moduleDir, "..", "..", "..", "tests", "distributed");
});
afterAll(() => {
rmSync(runRoot, { recursive: true, force: true });
});
async function planAndAssemble(input: {
projectDir: string;
workDir: string;
chunkSize: number;
}): Promise<string> {
const planDir = join(input.workDir, "plan");
const chunksDir = join(input.workDir, "chunks");
const outputPath = join(input.workDir, "frames");
mkdirSync(planDir, { recursive: true });
mkdirSync(chunksDir, { recursive: true });
const planResult = await plan(
input.projectDir,
{
fps: 30,
width: 320,
height: 180,
// png-sequence: every chunk emits a directory of PNGs and assemble()
// merges them with no re-encode. Byte equality at the file level is
// pixel equality. mp4 would muddy this because chunkSize directly
// affects keyframe placement in the bitstream.
format: "png-sequence",
chunkSize: input.chunkSize,
// Some adapter bundles (notably anime.js's IIFE) embed CSS-shaped
// strings inside their JS — `font-family: ui-monospace, monospace`
// for internal devtools styling. `validateNoSystemFonts` scans the
// entire compiled HTML and matches those JS string literals, which
// would false-positive every chunk-boundary fixture that loads
// such a bundle. Disable the check for this test only; the fixtures
// never display text and the byte-identity contract is independent
// of which fonts the page would resolve. This is the documented
// escape hatch for the option.
rejectOnSystemFonts: false,
},
planDir,
);
const chunkPaths: string[] = [];
for (let i = 0; i < planResult.chunkCount; i++) {
// png-sequence chunks are directories, not files.
const chunkPath = join(chunksDir, `chunk-${String(i).padStart(4, "0")}`);
await renderChunk(planDir, i, chunkPath);
chunkPaths.push(chunkPath);
}
const audioPath = join(planDir, "audio.aac");
const audioForAssemble = existsSync(audioPath) ? audioPath : null;
await assemble(planDir, chunkPaths, audioForAssemble, outputPath);
return outputPath;
}
describe("per-adapter chunk-boundary byte equality", () => {
// Two renders × ~5s each × cold-Chrome × six adapters can run long on the
// CI host. Per-adapter timeout keeps each `it()` failure local rather
// than smearing a single slow adapter across the suite cap.
const TIMEOUT_MS = 240_000;
for (const adapter of ADAPTERS) {
it(
`${adapter}: chunkSize=60 (N=1) vs chunkSize=15 (N=4) produces byte-identical mp4`,
async () => {
const fixtureDir = join(testsDistributedDir, `${adapter}-boundary`);
if (!existsSync(join(fixtureDir, "src", "index.html"))) {
throw new Error(
`[chunkBoundary.test] missing fixture src for adapter ${adapter}: ${fixtureDir}/src/index.html`,
);
}
const projectDir = join(fixtureDir, "src");
const workOne = join(runRoot, `${adapter}-n1`);
const workFour = join(runRoot, `${adapter}-n4`);
mkdirSync(workOne, { recursive: true });
mkdirSync(workFour, { recursive: true });
let outOne: string;
try {
outOne = await planAndAssemble({
projectDir,
workDir: workOne,
chunkSize: 60,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (HOST_CHROME_FAILURE_PATTERNS.test(message)) {
console.warn(
`[chunkBoundary.test] skipping ${adapter} — host Chrome can't render. ` +
"Docker harness covers the contract. Diagnostic:",
message.slice(0, 240),
);
return;
}
throw err;
}
const outFour = await planAndAssemble({
projectDir,
workDir: workFour,
chunkSize: 15,
});
// Per-frame byte equality across the two frames directories. A
// boundary regression in the adapter's seek-determinism would
// show up as one or more frames differing at the seam offsets
// (frames 15/30/45 — the chunk transitions in the N=4 run).
const framesOne = readdirSync(outOne)
.filter((n) => n.toLowerCase().endsWith(".png"))
.sort();
const framesFour = readdirSync(outFour)
.filter((n) => n.toLowerCase().endsWith(".png"))
.sort();
expect(framesOne.length).toBe(framesFour.length);
expect(framesOne).toEqual(framesFour);
for (let i = 0; i < framesOne.length; i++) {
const frameName = framesOne[i];
if (frameName === undefined) continue;
const a = readFileSync(join(outOne, frameName));
const b = readFileSync(join(outFour, frameName));
if (a.byteLength !== b.byteLength || !a.equals(b)) {
throw new Error(
`${adapter}: frame ${frameName} differs between N=1 and N=4 ` +
`(a=${a.byteLength}B, b=${b.byteLength}B)`,
);
}
}
},
TIMEOUT_MS,
);
}
it("expected fixture directories exist", () => {
// Cheap sanity check so a `bun test` filter that excludes the
// per-adapter `it()` blocks still verifies the fixture layout.
const present = readdirSync(testsDistributedDir).filter((name) => name.endsWith("-boundary"));
for (const adapter of ADAPTERS) {
expect(present).toContain(`${adapter}-boundary`);
}
});
});
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>chunk-boundary: anime.js</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/animejs@4.0.2/lib/anime.iife.min.js"></script>
<style>
body,
html {
margin: 0;
padding: 0;
width: 320px;
height: 180px;
background: #0f172a;
overflow: hidden;
}
#main-comp {
position: relative;
width: 320px;
height: 180px;
}
#box {
position: absolute;
left: 0;
top: 70px;
width: 40px;
height: 40px;
background: #ec4899;
border-radius: 6px;
}
</style>
</head>
<body>
<div
id="main-comp"
data-composition-id="main-comp"
data-width="320"
data-height="180"
data-start="0"
data-duration="2"
>
<div id="box"></div>
</div>
<script>
window.__hfAnime = window.__hfAnime || [];
const tl = anime.createTimeline({ autoplay: false });
tl.add(
"#box",
{ translateX: [0, 280], rotate: [0, 360], duration: 2000, ease: "linear" },
0,
);
window.__hfAnime.push({
seek: function (globalTimeMs) {
tl.seek(globalTimeMs);
},
pause: function () {
tl.pause();
},
play: function () {
tl.play();
},
});
const dur = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main-comp"] = dur;
dur.to({}, { duration: 2 }, 0);
</script>
</body>
</html>
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>chunk-boundary: CSS @keyframes</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body,
html {
margin: 0;
padding: 0;
width: 320px;
height: 180px;
background: #0f172a;
overflow: hidden;
}
#main-comp {
position: relative;
width: 320px;
height: 180px;
}
@keyframes slide {
from {
transform: translateX(0px) rotate(0deg);
}
to {
transform: translateX(280px) rotate(360deg);
}
}
#box {
position: absolute;
left: 0;
top: 70px;
width: 40px;
height: 40px;
background: #f97316;
border-radius: 6px;
animation: slide 2s linear forwards;
}
</style>
</head>
<body>
<div
id="main-comp"
data-composition-id="main-comp"
data-width="320"
data-height="180"
data-start="0"
data-duration="2"
>
<div id="box"></div>
</div>
<script>
// CSS animation is seek-driven by the HyperFrames CSS adapter — at
// each frame the runtime sets `animation-delay` so the keyframe
// playhead lands on the right point. A boundary regression here
// would show up as box position drift at frames 15, 30, 45.
// The empty GSAP timeline is just to satisfy the composition's
// duration-driver requirement.
const tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main-comp"] = tl;
tl.to({}, { duration: 2 }, 0);
</script>
</body>
</html>
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>chunk-boundary: GSAP</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body,
html {
margin: 0;
padding: 0;
width: 320px;
height: 180px;
background: #0f172a;
overflow: hidden;
}
#main-comp {
position: relative;
width: 320px;
height: 180px;
}
#box {
position: absolute;
left: 0;
top: 70px;
width: 40px;
height: 40px;
background: #6366f1;
border-radius: 6px;
}
</style>
</head>
<body>
<div
id="main-comp"
data-composition-id="main-comp"
data-width="320"
data-height="180"
data-start="0"
data-duration="2"
>
<div id="box"></div>
</div>
<script>
// Single GSAP timeline tween that straddles every chunk seam at
// chunkSize=15 (frames 15, 30, 45). Box translates from x=0 to x=280
// continuously across the 2s duration. Identical state on every
// frame regardless of chunk count.
const tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main-comp"] = tl;
tl.to("#box", { x: 280, duration: 2, ease: "none" }, 0);
tl.to("#box", { rotation: 360, duration: 2, ease: "none" }, 0);
</script>
</body>
</html>
@@ -0,0 +1,129 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>chunk-boundary: Lottie</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
<style>
body,
html {
margin: 0;
padding: 0;
width: 320px;
height: 180px;
background: #0f172a;
overflow: hidden;
}
#main-comp {
position: relative;
width: 320px;
height: 180px;
}
#lottie-host {
position: absolute;
inset: 0;
}
</style>
</head>
<body>
<div
id="main-comp"
data-composition-id="main-comp"
data-width="320"
data-height="180"
data-start="0"
data-duration="2"
>
<div id="lottie-host"></div>
</div>
<script>
// Minimal Lottie JSON: 60-frame composition (30fps × 2s) with a
// single rectangle layer animating its position from x=20 to x=280
// linearly. The chunk-boundary test asserts N=1 == N=4 byte-identical
// — any Lottie sub-frame interpolation drift across seams trips it.
const lottieJson = {
v: "5.7.0",
fr: 30,
ip: 0,
op: 60,
w: 320,
h: 180,
nm: "boundary",
ddd: 0,
assets: [],
layers: [
{
ddd: 0,
ind: 1,
ty: 4,
nm: "rect",
sr: 1,
ks: {
o: { a: 0, k: 100 },
p: {
a: 1,
k: [
{
i: { x: [1], y: [1] },
o: { x: [0], y: [0] },
t: 0,
s: [20, 90],
},
{ t: 60, s: [280, 90] },
],
},
r: {
a: 1,
k: [
{
i: { x: [1], y: [1] },
o: { x: [0], y: [0] },
t: 0,
s: [0],
},
{ t: 60, s: [360] },
],
},
s: { a: 0, k: [100, 100, 100] },
a: { a: 0, k: [0, 0, 0] },
},
shapes: [
{
ty: "rc",
p: { a: 0, k: [0, 0] },
s: { a: 0, k: [40, 40] },
r: { a: 0, k: 6 },
},
{
ty: "fl",
c: { a: 0, k: [0.337, 0.408, 0.941, 1] },
o: { a: 0, k: 100 },
},
],
ip: 0,
op: 60,
st: 0,
bm: 0,
},
],
};
const anim = lottie.loadAnimation({
container: document.getElementById("lottie-host"),
renderer: "svg",
loop: false,
autoplay: false,
animationData: lottieJson,
});
window.__hfLottie = window.__hfLottie || [];
window.__hfLottie.push(anim);
const dur = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main-comp"] = dur;
dur.to({}, { duration: 2 }, 0);
</script>
</body>
</html>
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>chunk-boundary: Three.js</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
body,
html {
margin: 0;
padding: 0;
width: 320px;
height: 180px;
background: #0f172a;
overflow: hidden;
}
#main-comp {
position: relative;
width: 320px;
height: 180px;
}
canvas {
display: block;
width: 320px;
height: 180px;
}
</style>
</head>
<body>
<div
id="main-comp"
data-composition-id="main-comp"
data-width="320"
data-height="180"
data-start="0"
data-duration="2"
></div>
<script>
// Minimal Three.js scene driven by window.__hfThreeTime. The runtime
// sets this per frame; the render loop reads it and derives all
// rotation/position from time-in-seconds so output is fully
// determined by the seek position.
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
document.getElementById("main-comp").appendChild(canvas);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f172a);
const camera = new THREE.PerspectiveCamera(45, 320 / 180, 0.1, 50);
camera.position.set(0, 0, 4);
const renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
renderer.setSize(320, 180, false);
const geo = new THREE.BoxGeometry(1, 1, 1);
const mat = new THREE.MeshStandardMaterial({ color: 0xa855f7 });
const cube = new THREE.Mesh(geo, mat);
scene.add(cube);
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const light = new THREE.DirectionalLight(0xffffff, 0.7);
light.position.set(2, 2, 3);
scene.add(light);
window.__hfThreeTime = 0;
function renderFrame() {
const t = window.__hfThreeTime || 0;
cube.rotation.x = t * Math.PI;
cube.rotation.y = t * Math.PI * 0.5;
renderer.render(scene, camera);
}
// Listen for the runtime's `hf-seek` event in case the engine pushes
// a seek before our render loop polls __hfThreeTime.
window.addEventListener("hf-seek", (e) => {
if (e && e.detail && typeof e.detail.time === "number") {
window.__hfThreeTime = e.detail.time;
}
renderFrame();
});
renderFrame();
const dur = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main-comp"] = dur;
dur.to({}, { duration: 2, onUpdate: renderFrame }, 0);
</script>
</body>
</html>
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>chunk-boundary: WAAPI</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<style>
body,
html {
margin: 0;
padding: 0;
width: 320px;
height: 180px;
background: #0f172a;
overflow: hidden;
}
#main-comp {
position: relative;
width: 320px;
height: 180px;
}
#box {
position: absolute;
left: 0;
top: 70px;
width: 40px;
height: 40px;
background: #22c55e;
border-radius: 6px;
}
</style>
</head>
<body>
<div
id="main-comp"
data-composition-id="main-comp"
data-width="320"
data-height="180"
data-start="0"
data-duration="2"
>
<div id="box"></div>
</div>
<script>
// Element.animate() / KeyframeEffect — the WAAPI surface. The
// HyperFrames runtime drives the animation's currentTime each frame.
// A boundary regression would manifest as box position discontinuity
// at chunk seam frames (15, 30, 45).
const box = document.getElementById("box");
const anim = box.animate(
[
{ transform: "translateX(0px) rotate(0deg)" },
{ transform: "translateX(280px) rotate(360deg)" },
],
{ duration: 2000, fill: "forwards", easing: "linear" },
);
anim.pause();
// The empty GSAP timeline owns the composition's duration; WAAPI is
// the actual animation under test.
const tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
window.__timelines["main-comp"] = tl;
tl.to({}, { duration: 2 }, 0);
</script>
</body>
</html>