mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-14 13:27:13 +00:00
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:
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user