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

## Description

Phase 4 of the distributed rendering plan: test fixtures (see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 4 + §10 test strategy). This is PR 4.7 of the Phase 4 remainder — the final fixture PR.

This PR adds six per-adapter chunk-boundary fixtures under `tests/distributed/{gsap,anime,three,lottie,css,waapi}-boundary/` plus a single `bun:test` driver (`chunkBoundary.test.ts`) that exercises each fixture's seek-determinism contract. Each fixture is a 60-frame composition (2s @ 30fps, 320×180) that drives the named adapter through the HyperFrames runtime's seek hook. The test renders each at `chunkSize=60` (N=1 chunk, no seams) and `chunkSize=15` (N=4 chunks, three seams at frames 15/30/45), then asserts every PNG frame is byte-identical across the two runs.

**Why png-sequence**: mp4 bitstreams encode keyframe placement directly. At `chunkSize=60` libx264 emits 1 IDR; at `chunkSize=15` it emits 4 IDRs at frames 0/15/30/45. Those are legitimately different bytes even when the captured pixels are identical. png-sequence's assemble path merges chunk frame directories with no re-encode, so per-frame byte equality is exactly pixel equality — the strongest contract a distributed render can satisfy.

**Fixture design** (each is ~60 lines of HTML):
- `gsap-boundary` — single GSAP `tl.to(...)` driving translateX + rotation linearly across 2s.
- `anime-boundary` — anime.js v4 timeline registered via `window.__hfAnime`.
- `three-boundary` — minimal Three.js scene; cube rotation derived from `window.__hfThreeTime`.
- `lottie-boundary` — inline Lottie JSON (rectangle layer animating position+rotation) loaded via `lottie-web` and registered via `window.__hfLottie`.
- `css-boundary` — pure `@keyframes` animation; the HyperFrames CSS adapter seeks via `animation-delay`.
- `waapi-boundary` — `element.animate()` with linear keyframes; runtime sets `currentTime` per frame.

The fixtures intentionally omit `meta.json` so the regression-harness discovery skips them with a clear `missing meta.json` log (they're driven exclusively by `chunkBoundary.test.ts`). The test passes `rejectOnSystemFonts: false` because some adapter bundles (notably anime.js's IIFE) embed CSS-shaped strings inside their JS source — `font-family: ui-monospace, monospace` for internal devtools styling — which `validateNoSystemFonts`'s document-wide regex would otherwise false-positive on every adapter fixture that loads such a bundle. The fixtures display no text, so the relaxed font validation doesn't affect the contract under test.

The 7th test case is a layout sanity check that asserts every expected `*-boundary` fixture directory exists.

## Testing

- `bun test packages/producer/src/services/distributed/chunkBoundary.test.ts` — 7 tests pass on host (6 adapters × byte-identical N=1 vs N=4 + the layout check, 41.6s)
- `bun test packages/producer/src/services/distributed/` — all 49 distributed unit tests pass (43.6s)
- `bun run --cwd packages/producer docker:test:distributed font-variant-numeric many-cuts gsap-letters-render-compat style-1-prod sub-composition-video mp4-h264-sdr png-sequence mov-prores mp4-h265-sdr -- --sequential` — full smoke set + all four prior stacked fixtures pass (9/9)
- `bunx oxlint` + `bunx oxfmt --check` clean
- `bunx tsc --noEmit` (producer package) clean

## After this stack lands

The Phase 4 fixture set is complete: PR 4.6 (#844) pins cross-worker idempotency; 4.2/4.3/4.4/4.5 (#845/#851/#847/#848) prove each format produces correct chunked output at the fixture's `minPsnr`; 4.3-pre (#850) added the codec knob H.265 needed; and this PR proves each first-party adapter's seek-determinism survives chunk seams. Phase 5 (CLI surface for `hyperframes plan/chunk/assemble`) and Phase 6 (AWS Lambda turnkey) are unblocked.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
James Russo
2026-05-14 23:09:56 -04:00
committed by GitHub
10 changed files with 739 additions and 69 deletions
@@ -153,38 +153,25 @@ export async function runDistributedSimulatedRender(
mkdirSync(planDir, { recursive: true });
mkdirSync(chunksDir, { recursive: true });
// Step A: plan. `codec` is only forwarded when the format actually
// accepts it — `plan()` throws if codec is set for a non-mp4 format,
// and a caller passing `format: "mov", codec: undefined` would still
// surface that field in the resulting object. We omit it conditionally
// to keep the off-path planDir identical to pre-codec-knob output.
// Step A: plan. `plan()` throws when `codec` is set with a non-mp4 format,
// but `codec: undefined` is a no-op — so we forward it directly for mp4
// and elide it for the others rather than branching the entire config.
// hdrMode is pinned to force-sdr so the harness's behavior is independent
// of any future auto-detect changes.
const planResult = await plan(
input.projectDir,
input.format === "mp4"
? {
fps: input.fps,
width: 1920,
height: 1080,
format: "mp4",
codec: input.codec,
chunkSize: input.chunkSize,
maxParallelChunks: input.maxParallelChunks,
hdrMode: "force-sdr",
}
: {
fps: input.fps,
// Required-by-type but overridden by the composition's own attrs;
// see docstring above. Any positive integer works.
width: 1920,
height: 1080,
format: input.format,
chunkSize: input.chunkSize,
maxParallelChunks: input.maxParallelChunks,
// Force the SDR path explicitly — `auto` would still resolve to
// force-sdr in distributed mode, but pinning it here keeps the
// harness's behavior independent of any future auto-detect changes.
hdrMode: "force-sdr",
},
{
fps: input.fps,
// Required-by-type but overridden by the composition's `data-width` /
// `data-height` attrs; any positive integer works.
width: 1920,
height: 1080,
format: input.format,
...(input.format === "mp4" && input.codec !== undefined ? { codec: input.codec } : {}),
chunkSize: input.chunkSize,
maxParallelChunks: input.maxParallelChunks,
hdrMode: "force-sdr",
},
planDir,
);
+42 -36
View File
@@ -372,12 +372,10 @@ function discoverTestSuites(
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.
// `tests/distributed/<name>/` holds fixtures authored for the
// distributed pipeline. Recurse one level deeper so each `<name>`
// becomes a first-class fixture ID the user can target on the CLI
// without a namespace prefix.
if (entry === "distributed") {
for (const sub of readdirSync(dir)) {
const subDir = join(dir, sub);
@@ -547,9 +545,7 @@ function saveFailureDetails(
snapshotHtml?: string,
): void {
const failuresDir = join(suite.dir, "failures");
if (!existsSync(failuresDir)) {
mkdirSync(failuresDir, { recursive: true });
}
mkdirSync(failuresDir, { recursive: true });
// Save compilation failures
if (result.compilation && !result.compilation.passed) {
@@ -608,30 +604,36 @@ function saveFailureDetails(
const framesToExtract = failedCheckpoints.slice(0, 10);
if (framesToExtract.length > 0) {
const framesDir = join(failuresDir, "frames");
if (!existsSync(framesDir)) {
mkdirSync(framesDir, { recursive: true });
}
mkdirSync(framesDir, { recursive: true });
const renderedIsDir =
existsSync(renderedVideoPath) && statSync(renderedVideoPath).isDirectory();
logPretty(`Extracting ${framesToExtract.length} failed frames...`, "📸");
// For directory output, sort both frame lists once — they're static for
// the duration of the failure-extraction loop, so the per-checkpoint
// readdir+filter+sort the loop did before was wasted syscalls.
const renderedDirFrames = renderedIsDir
? readdirSync(renderedVideoPath)
.filter((n) => n.toLowerCase().endsWith(".png"))
.sort()
: null;
const snapshotDirFrames = renderedIsDir
? readdirSync(snapshotVideoPath)
.filter((n) => n.toLowerCase().endsWith(".png"))
.sort()
: null;
for (const checkpoint of framesToExtract) {
const timeStr = checkpoint.time.toFixed(2).replace(".", "_");
try {
if (renderedIsDir) {
if (renderedDirFrames && snapshotDirFrames) {
const frameIndex = Math.max(
0,
Math.round(checkpoint.time * fpsToNumber(suite.meta.renderConfig.fps)),
);
const renderedFrames = readdirSync(renderedVideoPath)
.filter((n) => n.toLowerCase().endsWith(".png"))
.sort();
const snapshotFrames = readdirSync(snapshotVideoPath)
.filter((n) => n.toLowerCase().endsWith(".png"))
.sort();
const renderedFrame = renderedFrames[frameIndex];
const snapshotFrame = snapshotFrames[frameIndex];
const renderedFrame = renderedDirFrames[frameIndex];
const snapshotFrame = snapshotDirFrames[frameIndex];
if (renderedFrame !== undefined) {
copyFileSync(
join(renderedVideoPath, renderedFrame),
@@ -717,17 +719,22 @@ async function runTestSuite(
const tempDownloadDir = join(tempRoot, "downloads");
const outputFormat = suite.meta.renderConfig.format ?? "mp4";
const isPngSequence = outputFormat === "png-sequence";
// png-sequence output is a directory; encoded video outputs (mp4/mov/webm)
// are single files. `outputSuffix` is appended to the in-temp + baseline
// names so both shapes round-trip cleanly.
const outputSuffix = isPngSequence
? ""
: outputFormat === "mp4"
? ".mp4"
: outputFormat === "mov"
? ".mov"
: ".webm";
const outputBasename = isPngSequence ? "frames" : `output${outputSuffix}`;
// png-sequence output is a directory (basename = "frames"); encoded video
// formats produce a single file (basename = "output.<ext>"). One lookup
// covers both shapes for the in-temp render and the on-disk baseline.
// `VIDEO_EXT` is intentionally typed against only the encoded-video set —
// the `isPngSequence` ternary below short-circuits before `outputFormat`
// can be `"png-sequence"`, but TS can't narrow through that, so we
// assert the narrowing at the indexing site rather than over-widening
// the lookup table.
const VIDEO_EXT: Record<"mp4" | "mov" | "webm", string> = {
mp4: ".mp4",
mov: ".mov",
webm: ".webm",
};
const outputBasename = isPngSequence
? "frames"
: `output${VIDEO_EXT[outputFormat as "mp4" | "mov" | "webm"]}`;
const renderedOutputPath = join(tempRoot, outputBasename);
// Snapshot files stored in test's output/ directory. For png-sequence the
@@ -882,10 +889,9 @@ async function runTestSuite(
}
if (isPngSequence) {
// Frames directory — recursive copy so every PNG lands at
// `<snapshotDir>/frames/<frame-N>.png`.
if (existsSync(snapshotVideoPath)) {
rmSync(snapshotVideoPath, { recursive: true, force: true });
}
// `<snapshotDir>/frames/<frame-N>.png`. `rmSync(..., force: true)`
// tolerates a missing path, so the prior existsSync gate was redundant.
rmSync(snapshotVideoPath, { recursive: true, force: true });
cpSync(renderedOutputPath, snapshotVideoPath, { recursive: true });
} else {
copyFileSync(renderedOutputPath, snapshotVideoPath);
@@ -0,0 +1,197 @@
/**
* 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*. Anything
* weaker means the worker's seek-determinism leaks across chunk boundaries.
*
* Output 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 is exactly pixel equality.
*
* 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 fixtures intentionally lack a `meta.json` so they're invisible
* to the regression harness; this test owns them. On hosts whose
* chrome-headless-shell can't render (no SwiftShader / missing GL stack),
* each subtest soft-skips and the Docker harness covers the contract.
*/
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;
// Every adapter fixture is a 2-second composition at 30fps. Pin the absolute
// count so a regression that produces fewer frames in both runs (e.g. a
// probe stage that reads duration as 0s) doesn't pass vacuously.
const EXPECTED_FRAME_COUNT = 60;
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,
// anime.js's IIFE bundle embeds `font-family: ui-monospace, monospace`
// as a string literal inside its JS, which `validateNoSystemFonts`'s
// document-wide regex false-positives. These fixtures display no text,
// so disabling the check (the documented escape hatch on this flag) is
// safe.
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 frames`,
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 });
// Soft-skip when host Chrome can't render. Wrap *both* renders —
// cold-Chrome / SwiftShader flakes happen on the second render
// as readily as the first, and a hard-fail on the N=4 path would
// diverge from the rest of the harness's soft-skip convention.
const runRender = async (workDir: string, chunkSize: number): Promise<string | null> => {
try {
return await planAndAssemble({ projectDir, workDir, chunkSize });
} 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 null;
}
throw err;
}
};
const outOne = await runRender(workOne, 60);
if (outOne === null) return;
const outFour = await runRender(workFour, 15);
if (outFour === null) return;
// 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();
// Pin the absolute count, not just equality between the two runs.
// Otherwise a regression that truncates BOTH renders identically
// (e.g. a probe stage that misreads duration as 0s) would pass
// vacuously — `0 === 0` is true.
expect(framesOne.length).toBe(EXPECTED_FRAME_COUNT);
expect(framesFour.length).toBe(EXPECTED_FRAME_COUNT);
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`);
}
});
});
@@ -242,9 +242,8 @@ describe("plan() — codec knob", () => {
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
) as Record<string, unknown>;
expect(encoder.encoder).toBe("libx265-software");
// SDR 8-bit yuv420p, same as h264. Distributed mode is SDR-only —
// anyone reading this and tempted to bump to 10-bit, that's HDR
// territory and lives in v1.5.
// SDR 8-bit yuv420p, same as h264 — distributed mode is SDR-only and
// 10-bit / HDR pixelFormat selection is not exposed on this surface.
expect(encoder.pixelFormat).toBe("yuv420p");
},
TIMEOUT_MS,
@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>chunk-boundary: anime.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/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>