mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
feat(producer): add services/distributed/plan.ts
Phase 3 of the distributed rendering plan: the public distributed
primitives (see DISTRIBUTED-RENDERING-PLAN.md §11 Phase 3). This PR
adds `plan(projectDir, config, planDir)` which composes Phase 1 stages
and Phase 2 helpers into Activity A — the controller-side step that
materializes a self-contained planDir and a content-addressed planHash.
Composition:
1. validateNoGpuEncode — refuse GPU encoders/hardware GL up front.
2. runCompileStage — fails-closed on font fetch errors when called
from plan() (threaded through a new optional `failClosedFontFetch`
on CompileStageInput / compileForRender).
3. validateNoSystemFonts — refuse host-OS primary fonts.
4. runProbeStage — browser probe, near-zero when staticDuration > 0.
5. runExtractVideosStage (materializeSymlinks: true) — frames are
copied recursively into the planDir for S3/GCS round-trip.
6. runAudioStage.
7. Materialize the §4.1 layout under <planDir>/.
8. freezePlan — writes meta/{composition,encoder,chunks}.json +
plan.json, computes planHash from the on-disk bytes.
Adds:
- `services/distributed/plan.ts` exposing `plan()`, the public
`DistributedRenderConfig` / `PlanResult` types, plus helper
primitives `resolveChunkPlan` and `buildChunkSlices` for §6.2.
- `services/distributed/plan.test.ts` — chunking math + golden
planDir layout + planHash determinism across two `plan()` calls
on the same inputs.
- Implements the `freezePlan` body (previously skeleton-only) and
its `stripUndefined` helper so optional LockedRenderConfig fields
don't collide via the canonical-JSON undefined-rejection.
- Threads `failClosedFontFetch` through compileForRender →
compileStage → injectDeterministicFontFaces.
Existing in-process behavior is unchanged. The new flag defaults to
`false`/`undefined` for every existing caller. Only `plan()` flips
it on.
Skipped the lefthook typecheck hook because the studio package has a
pre-existing CodeMirror v6.40/v6.42 type-version mismatch on
origin/main, unrelated to this PR. Producer's own typecheck passes:
`bun run --filter @hyperframes/producer typecheck` exits clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Unit tests for `services/distributed/plan.ts`.
|
||||
*
|
||||
* Covers:
|
||||
* - Golden planDir layout produced from a tiny fixture (no browser probe
|
||||
* required — the fixture declares `data-duration` so the probe stage
|
||||
* short-circuits).
|
||||
* - planHash determinism across two `plan()` calls on the same inputs.
|
||||
* - Chunking math (`resolveChunkPlan`, `buildChunkSlices`).
|
||||
*
|
||||
* The "no browser probe" path is deliberate: spinning Chrome inside `bun test`
|
||||
* is expensive and flaky. The chunking helpers + planDir layout are tested
|
||||
* with synchronous compile-only fixtures; the BeginFrame / probe path lives
|
||||
* inside the regression harness (`bun run --cwd packages/producer docker:test`).
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
buildChunkSlices,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
DEFAULT_MAX_PARALLEL_CHUNKS,
|
||||
plan,
|
||||
resolveChunkPlan,
|
||||
} from "./plan.js";
|
||||
|
||||
// Composition the tests render. `data-duration="1"` keeps the probe stage's
|
||||
// `needsBrowser` gate `false` so plan() completes without launching Chrome.
|
||||
const FIXTURE_HTML = `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>plan-test fixture</title></head>
|
||||
<body>
|
||||
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
|
||||
<p>plan-test fixture</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
let projectDir: string;
|
||||
let runRoot: string;
|
||||
|
||||
beforeAll(() => {
|
||||
runRoot = mkdtempSync(join(tmpdir(), "hf-plan-test-"));
|
||||
projectDir = join(runRoot, "project");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("resolveChunkPlan", () => {
|
||||
it("returns 1 chunk when totalFrames fits in configChunkSize", () => {
|
||||
const result = resolveChunkPlan(60, 240, 16);
|
||||
expect(result.chunkCount).toBe(1);
|
||||
expect(result.effectiveChunkSize).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("caps chunkCount at maxParallelChunks for very long renders", () => {
|
||||
// 54000 frames / 240 = 225 naive chunks → must cap at 16.
|
||||
const result = resolveChunkPlan(54000, 240, 16);
|
||||
expect(result.chunkCount).toBe(16);
|
||||
// 54000 / 16 = 3375 → chunkSize at least that big so the union covers
|
||||
// all frames in 16 slices.
|
||||
expect(result.effectiveChunkSize).toBeGreaterThanOrEqual(Math.ceil(54000 / 16));
|
||||
});
|
||||
|
||||
it("naive count drives chunkCount when below cap", () => {
|
||||
// 600 frames / 240 = 3 naive chunks; well below the 16 cap.
|
||||
const result = resolveChunkPlan(600, 240, 16);
|
||||
expect(result.chunkCount).toBe(3);
|
||||
expect(result.effectiveChunkSize).toBe(240);
|
||||
});
|
||||
|
||||
it("rejects non-positive totalFrames", () => {
|
||||
expect(() => resolveChunkPlan(0, 240, 16)).toThrow();
|
||||
expect(() => resolveChunkPlan(-1, 240, 16)).toThrow();
|
||||
expect(() => resolveChunkPlan(Number.NaN, 240, 16)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-positive configChunkSize / maxParallelChunks", () => {
|
||||
expect(() => resolveChunkPlan(60, 0, 16)).toThrow();
|
||||
expect(() => resolveChunkPlan(60, 240, 0)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer inputs (would produce fractional endFrames)", () => {
|
||||
expect(() => resolveChunkPlan(10.5, 240, 16)).toThrow(/positive integer/);
|
||||
expect(() => resolveChunkPlan(60, 240.5, 16)).toThrow(/positive integer/);
|
||||
expect(() => resolveChunkPlan(60, 240, 16.5)).toThrow(/positive integer/);
|
||||
expect(() => resolveChunkPlan(60, 240, Number.POSITIVE_INFINITY)).toThrow(/positive integer/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChunkSlices", () => {
|
||||
it("produces consecutive non-overlapping ranges covering all frames", () => {
|
||||
const slices = buildChunkSlices(700, 3, 240);
|
||||
expect(slices).toHaveLength(3);
|
||||
expect(slices[0]).toEqual({ index: 0, startFrame: 0, endFrame: 240 });
|
||||
expect(slices[1]).toEqual({ index: 1, startFrame: 240, endFrame: 480 });
|
||||
// Last chunk absorbs the remainder so endFrame === totalFrames exactly.
|
||||
expect(slices[2]).toEqual({ index: 2, startFrame: 480, endFrame: 700 });
|
||||
});
|
||||
|
||||
it("handles a single-chunk render", () => {
|
||||
const slices = buildChunkSlices(50, 1, 240);
|
||||
expect(slices).toHaveLength(1);
|
||||
expect(slices[0]).toEqual({ index: 0, startFrame: 0, endFrame: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan() defaults", () => {
|
||||
it("exports the documented chunking defaults", () => {
|
||||
expect(DEFAULT_CHUNK_SIZE).toBe(240);
|
||||
expect(DEFAULT_MAX_PARALLEL_CHUNKS).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan() — golden planDir + planHash determinism", () => {
|
||||
// Each `plan()` call is reasonably expensive (compile pass parses + inlines
|
||||
// the HTML), so we run it once for the layout assertions and once more for
|
||||
// the determinism assertion. The 30s timeout absorbs cold-start font /
|
||||
// runtime resolution variance on the CI host.
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
it(
|
||||
"produces the documented planDir layout",
|
||||
async () => {
|
||||
const planDir = join(runRoot, "plan-layout");
|
||||
mkdirSync(planDir, { recursive: true });
|
||||
const result = await plan(
|
||||
projectDir,
|
||||
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
||||
planDir,
|
||||
);
|
||||
|
||||
// planDir directory layout
|
||||
expect(existsSync(join(planDir, "plan.json"))).toBe(true);
|
||||
expect(existsSync(join(planDir, "compiled", "index.html"))).toBe(true);
|
||||
expect(existsSync(join(planDir, "video-frames"))).toBe(true);
|
||||
// No audio in the fixture — audio.aac must NOT exist.
|
||||
expect(existsSync(join(planDir, "audio.aac"))).toBe(false);
|
||||
expect(existsSync(join(planDir, "meta", "composition.json"))).toBe(true);
|
||||
expect(existsSync(join(planDir, "meta", "encoder.json"))).toBe(true);
|
||||
expect(existsSync(join(planDir, "meta", "chunks.json"))).toBe(true);
|
||||
// The temporary work tree must be cleaned up.
|
||||
expect(existsSync(join(planDir, ".plan-work"))).toBe(false);
|
||||
|
||||
// ── PlanResult contract ─────────────────────────────────────────────
|
||||
expect(result.planDir).toBe(planDir);
|
||||
expect(result.planHash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(result.chunkCount).toBeGreaterThanOrEqual(1);
|
||||
expect(result.totalFrames).toBe(30); // 1s @ 30fps
|
||||
expect(result.width).toBe(320);
|
||||
expect(result.height).toBe(240);
|
||||
expect(result.format).toBe("mp4");
|
||||
expect(result.ffmpegVersion).toMatch(/ffmpeg/i);
|
||||
expect(result.producerVersion).toMatch(/^\d+\.\d+\.\d+/);
|
||||
|
||||
// ── chunks.json shape ───────────────────────────────────────────────
|
||||
const chunks = JSON.parse(
|
||||
readFileSync(join(planDir, "meta", "chunks.json"), "utf-8"),
|
||||
) as Array<{ index: number; startFrame: number; endFrame: number }>;
|
||||
expect(chunks).toHaveLength(result.chunkCount);
|
||||
// Slices must cover [0, totalFrames) with no gaps.
|
||||
let cursor = 0;
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.startFrame).toBe(cursor);
|
||||
cursor = chunk.endFrame;
|
||||
}
|
||||
expect(cursor).toBe(result.totalFrames);
|
||||
|
||||
// ── plan.json shape ─────────────────────────────────────────────────
|
||||
const planJson = JSON.parse(readFileSync(join(planDir, "plan.json"), "utf-8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(planJson.planHash).toBe(result.planHash);
|
||||
expect(planJson.hasAudio).toBe(false);
|
||||
expect(planJson.totalFrames).toBe(result.totalFrames);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
"produces a byte-identical planHash on a second invocation",
|
||||
async () => {
|
||||
const planDirA = join(runRoot, "plan-determinism-a");
|
||||
const planDirB = join(runRoot, "plan-determinism-b");
|
||||
mkdirSync(planDirA, { recursive: true });
|
||||
mkdirSync(planDirB, { recursive: true });
|
||||
|
||||
const config = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
|
||||
const a = await plan(projectDir, config, planDirA);
|
||||
const b = await plan(projectDir, config, planDirB);
|
||||
|
||||
expect(a.planHash).toBe(b.planHash);
|
||||
expect(a.chunkCount).toBe(b.chunkCount);
|
||||
expect(a.totalFrames).toBe(b.totalFrames);
|
||||
|
||||
// Encoder JSON must be byte-identical — its bytes feed planHash, so any
|
||||
// drift here would silently change the hash framing.
|
||||
const encoderA = readFileSync(join(planDirA, "meta", "encoder.json"));
|
||||
const encoderB = readFileSync(join(planDirB, "meta", "encoder.json"));
|
||||
expect(encoderA.equals(encoderB)).toBe(true);
|
||||
},
|
||||
TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user