mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
test(producer): add chunk-boundary fixtures per first-party adapter
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user