mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
898 lines
36 KiB
TypeScript
898 lines
36 KiB
TypeScript
// fallow-ignore-file code-duplication
|
|
/**
|
|
* 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 { applyConcreteGpuScreenshotClamp, buildChromeArgs } from "@hyperframes/engine";
|
|
import { recomputePlanHashFromPlanDir } from "../render/stages/freezePlan.js";
|
|
import { RenderQualityError } from "../renderOrchestrator.js";
|
|
import { CURRENT_PLAN_PROTOCOL } from "./planProtocol.js";
|
|
import {
|
|
applyDistributedAudioWarningPolicy,
|
|
buildChunkSlices,
|
|
DEFAULT_CHUNK_SIZE,
|
|
DEFAULT_MAX_PARALLEL_CHUNKS,
|
|
MIN_CHUNK_SIZE,
|
|
plan,
|
|
resolveDistributedEngineConfig,
|
|
resolveChunkPlan,
|
|
} from "./plan.js";
|
|
import { buildSyntheticRenderJob } from "./shared.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("distributed warning policy", () => {
|
|
const createJob = (strictness: "strict" | "best-effort") =>
|
|
buildSyntheticRenderJob({
|
|
fps: { num: 30, den: 1 },
|
|
format: "mp4",
|
|
quality: "high",
|
|
hdrMode: "force-sdr",
|
|
strictness,
|
|
entryFile: "index.html",
|
|
});
|
|
|
|
it("rejects distributed audio degradation in strict mode", () => {
|
|
const job = createJob("strict");
|
|
expect(() => applyDistributedAudioWarningPolicy(job, "mix failed")).toThrow(RenderQualityError);
|
|
expect(job.warnings.map((warning) => warning.code)).toEqual(["audio_processing_failed"]);
|
|
});
|
|
|
|
it("rejects distributed audio degradation in best-effort mode", () => {
|
|
const job = createJob("best-effort");
|
|
expect(() =>
|
|
applyDistributedAudioWarningPolicy(job, "mix failed", [
|
|
{
|
|
stage: "mix",
|
|
reason: "ffmpeg_unsupported",
|
|
owner: "system",
|
|
retryable: false,
|
|
detail: "Option not found",
|
|
},
|
|
]),
|
|
).toThrow(RenderQualityError);
|
|
expect(job.warnings.map((warning) => warning.code)).toEqual(["audio_processing_failed"]);
|
|
expect(job.warnings[0]?.details).toEqual(
|
|
expect.objectContaining({
|
|
failureReasons: ["ffmpeg_unsupported"],
|
|
failureStages: ["mix"],
|
|
failureOwner: "system",
|
|
retryable: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("only marks a multi-cause audio failure retryable when every cause is retryable", () => {
|
|
const job = createJob("best-effort");
|
|
expect(() =>
|
|
applyDistributedAudioWarningPolicy(job, "mixed failure", [
|
|
{
|
|
stage: "download",
|
|
reason: "download_failed",
|
|
owner: "system",
|
|
retryable: true,
|
|
detail: "temporary download failure",
|
|
},
|
|
{
|
|
stage: "prepare",
|
|
reason: "invalid_media",
|
|
owner: "user",
|
|
retryable: false,
|
|
detail: "invalid media",
|
|
},
|
|
]),
|
|
).toThrow(RenderQualityError);
|
|
expect(job.warnings[0]?.details).toEqual(
|
|
expect.objectContaining({
|
|
failureOwner: "system",
|
|
retryable: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not invent ownership or retryability for legacy untyped failures", () => {
|
|
const job = createJob("best-effort");
|
|
expect(() => applyDistributedAudioWarningPolicy(job, "legacy failure")).toThrow(
|
|
RenderQualityError,
|
|
);
|
|
expect(job.warnings[0]?.details?.failureOwner).toBeUndefined();
|
|
expect(job.warnings[0]?.details?.retryable).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("distributed synthetic render job", () => {
|
|
it("threads render variables into the plan browser probe job", () => {
|
|
const variables = {
|
|
voiceoverSrc: "assets/voiceover.wav",
|
|
narrationDurationSeconds: 56.738,
|
|
};
|
|
const job = buildSyntheticRenderJob({
|
|
fps: { num: 30, den: 1 },
|
|
format: "mp4",
|
|
quality: "high",
|
|
hdrMode: "force-sdr",
|
|
entryFile: "index.html",
|
|
variables,
|
|
});
|
|
|
|
expect(job.config.variables).toEqual(variables);
|
|
});
|
|
|
|
it("keeps the production software-GPU launch on BeginFrame control", () => {
|
|
const cfg = resolveDistributedEngineConfig({
|
|
fps: 30,
|
|
width: 320,
|
|
height: 240,
|
|
format: "mp4",
|
|
});
|
|
const forceScreenshot = applyConcreteGpuScreenshotClamp(
|
|
cfg.forceScreenshot,
|
|
"software",
|
|
cfg,
|
|
{},
|
|
);
|
|
const captureMode = forceScreenshot ? "screenshot" : "beginframe";
|
|
const args = buildChromeArgs({ width: 320, height: 240, captureMode, platform: "linux" }, cfg);
|
|
|
|
expect(forceScreenshot).toBe(false);
|
|
expect(args).toContain("--enable-begin-frame-control");
|
|
});
|
|
});
|
|
|
|
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/);
|
|
});
|
|
|
|
// ── Auto-size when configChunkSize is undefined ───────────────────────
|
|
// The auto-sizer picks `max(MIN_CHUNK_SIZE, ceil(totalFrames /
|
|
// maxParallelChunks))` whenever the caller leaves `chunkSize` undefined,
|
|
// honoring `maxParallelChunks` instead of clamping at a 240-frame default.
|
|
|
|
it("explicit chunkSize wins: 660 frames + chunkSize=240 + maxParallelChunks=16 → 3 chunks", () => {
|
|
// Regression guard for the "explicit number still works" half of the
|
|
// contract — passing 240 explicitly must not get auto-sized.
|
|
const result = resolveChunkPlan(660, 240, 16);
|
|
expect(result.chunkCount).toBe(3);
|
|
expect(result.effectiveChunkSize).toBe(240);
|
|
});
|
|
|
|
it("auto-sizes when chunkSize=undefined: 660 frames + maxParallelChunks=16 → 16 chunks", () => {
|
|
// ceil(660 / 16) = 42; max(MIN_CHUNK_SIZE=10, 42) = 42. naiveCount =
|
|
// ceil(660 / 42) = 16, which lands exactly at the cap.
|
|
const result = resolveChunkPlan(660, undefined, 16);
|
|
expect(result.chunkCount).toBe(16);
|
|
expect(result.effectiveChunkSize).toBe(42);
|
|
});
|
|
|
|
it("auto-size floor: tiny renders cap at MIN_CHUNK_SIZE rather than fragmenting infinitely", () => {
|
|
// 50 frames / 16 workers naively gives a 4-frame chunk size, which
|
|
// would produce 13 chunks of 4 frames each — per-chunk fixed overhead
|
|
// dwarfs the parallelism gain. The MIN_CHUNK_SIZE=10 floor pins
|
|
// chunkSize at 10, producing ceil(50/10) = 5 chunks instead.
|
|
const result = resolveChunkPlan(50, undefined, 16);
|
|
expect(result.chunkCount).toBe(5);
|
|
expect(result.effectiveChunkSize).toBe(MIN_CHUNK_SIZE);
|
|
});
|
|
|
|
it("tightens chunkCount so an explicit small chunkSize leaves no empty trailing slice", () => {
|
|
// 121 frames, chunkSize=10, maxParallelChunks=12: ceil(121/10)=13 naive →
|
|
// capped at 12, but effectiveChunkSize rounds up to ceil(121/12)=11. Eleven
|
|
// 11-frame chunks already cover [0,121), so a 12th would be the empty slice
|
|
// [121,121) that renderChunk rejects (framesInChunk <= 0). chunkCount must
|
|
// tighten to 11.
|
|
const result = resolveChunkPlan(121, 10, 12);
|
|
expect(result.effectiveChunkSize).toBe(11);
|
|
expect(result.chunkCount).toBe(11);
|
|
const slices = buildChunkSlices(121, result.chunkCount, result.effectiveChunkSize);
|
|
expect(slices).toHaveLength(11);
|
|
expect(slices[slices.length - 1]).toEqual({ index: 10, startFrame: 110, endFrame: 121 });
|
|
});
|
|
|
|
it("never emits an empty or inverted slice across a grid of explicit chunk sizes", () => {
|
|
for (const totalFrames of [37, 50, 121, 333, 1000, 4001]) {
|
|
for (const maxParallel of [2, 4, 12, 16, 40]) {
|
|
for (const chunkSize of [10, 11, 15, 24, 100]) {
|
|
const { chunkCount, effectiveChunkSize } = resolveChunkPlan(
|
|
totalFrames,
|
|
chunkSize,
|
|
maxParallel,
|
|
);
|
|
expect(chunkCount).toBeLessThanOrEqual(maxParallel);
|
|
const slices = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
|
|
let cursor = 0;
|
|
for (const s of slices) {
|
|
expect(s.startFrame).toBe(cursor); // contiguous from 0
|
|
expect(s.endFrame).toBeGreaterThan(s.startFrame); // non-empty
|
|
cursor = s.endFrame;
|
|
}
|
|
expect(cursor).toBe(totalFrames); // union is exactly [0, totalFrames)
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── targetChunkFrames (optional per-chunk frame ceiling) ──
|
|
|
|
it("targetChunkFrames omitted is a no-op: identical to the 3-arg auto-sized result", () => {
|
|
// The default path must be byte-identical whether the 4th arg is absent or
|
|
// explicitly undefined.
|
|
for (const totalFrames of [50, 660, 1466, 54000]) {
|
|
for (const maxParallel of [8, 16, 64]) {
|
|
const base = resolveChunkPlan(totalFrames, undefined, maxParallel);
|
|
const withUndef = resolveChunkPlan(totalFrames, undefined, maxParallel, undefined);
|
|
expect(withUndef).toEqual(base);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("targetChunkFrames collapses a short video to fewer chunks than the parallelism cap", () => {
|
|
// 1466 frames, target 300, cap 16: ceil(1466/300)=5 chunks (not 16), each
|
|
// ~293 frames. Fewer chunks → less per-chunk fixed overhead.
|
|
const result = resolveChunkPlan(1466, undefined, 16, 300);
|
|
expect(result.chunkCount).toBe(5);
|
|
expect(result.effectiveChunkSize).toBeLessThanOrEqual(300);
|
|
});
|
|
|
|
it("targetChunkFrames bounds a long video's per-chunk frames, adding chunks up to the cap", () => {
|
|
// 54000 frames (30 min @30fps), target 1600, cap 64: ceil(54000/1600)=34
|
|
// chunks, each <= 1600 frames so per-chunk render time stays under budget.
|
|
const result = resolveChunkPlan(54000, undefined, 64, 1600);
|
|
expect(result.chunkCount).toBe(34);
|
|
expect(result.effectiveChunkSize).toBeLessThanOrEqual(1600);
|
|
});
|
|
|
|
it("targetChunkFrames is clamped by maxParallelChunks: an extreme length stays at the cap (still over budget)", () => {
|
|
// 216000 frames (2 h), target 1600, cap 64: needs 135 chunks but clamps to
|
|
// 64; per-chunk frames then exceed the target — the genuine tier ceiling.
|
|
const result = resolveChunkPlan(216000, undefined, 64, 1600);
|
|
expect(result.chunkCount).toBe(64);
|
|
expect(result.effectiveChunkSize).toBeGreaterThan(1600);
|
|
});
|
|
|
|
it("explicit chunkSize wins over targetChunkFrames (targetChunkFrames is a no-op)", () => {
|
|
const withTarget = resolveChunkPlan(54000, 240, 64, 1600);
|
|
const chunkSizeOnly = resolveChunkPlan(54000, 240, 64);
|
|
expect(withTarget).toEqual(chunkSizeOnly);
|
|
});
|
|
|
|
it("rejects a non-positive or non-integer targetChunkFrames", () => {
|
|
expect(() => resolveChunkPlan(1466, undefined, 16, 0)).toThrow(/positive integer/);
|
|
expect(() => resolveChunkPlan(1466, undefined, 16, -100)).toThrow(/positive integer/);
|
|
expect(() => resolveChunkPlan(1466, undefined, 16, 300.5)).toThrow(/positive integer/);
|
|
});
|
|
|
|
it("never emits an empty or inverted slice across a grid of targetChunkFrames", () => {
|
|
for (const totalFrames of [37, 660, 1466, 12793, 54000]) {
|
|
for (const maxParallel of [8, 16, 64]) {
|
|
for (const target of [100, 300, 1600]) {
|
|
const { chunkCount, effectiveChunkSize } = resolveChunkPlan(
|
|
totalFrames,
|
|
undefined,
|
|
maxParallel,
|
|
target,
|
|
);
|
|
expect(chunkCount).toBeGreaterThanOrEqual(1);
|
|
expect(chunkCount).toBeLessThanOrEqual(maxParallel);
|
|
const slices = buildChunkSlices(totalFrames, chunkCount, effectiveChunkSize);
|
|
let cursor = 0;
|
|
for (const s of slices) {
|
|
expect(s.startFrame).toBe(cursor);
|
|
expect(s.endFrame).toBeGreaterThan(s.startFrame);
|
|
cursor = s.endFrame;
|
|
}
|
|
expect(cursor).toBe(totalFrames);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
expect(MIN_CHUNK_SIZE).toBe(10);
|
|
});
|
|
});
|
|
|
|
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 });
|
|
// Pin chunkSize=240 so this fixture exercises the single-chunk path
|
|
// (totalFrames=30 → ceil(30/240)=1 chunk). The auto-sized variant
|
|
// (chunkSize=undefined) is exercised by the dedicated test below.
|
|
const result = await plan(
|
|
projectDir,
|
|
{ fps: 30, width: 320, height: 240, format: "mp4", chunkSize: 240 },
|
|
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.planProtocol).toEqual(CURRENT_PLAN_PROTOCOL);
|
|
expect(result.planHash).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(result.chunkCount).toBe(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.protocol).toEqual(CURRENT_PLAN_PROTOCOL);
|
|
expect(planJson.hasAudio).toBe(false);
|
|
expect(planJson.totalFrames).toBe(result.totalFrames);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"auto-sizes chunkSize end-to-end when caller omits it",
|
|
async () => {
|
|
// Integration check that the auto-sizer wired through plan() actually
|
|
// produces multi-chunk output for the same fixture that single-chunks
|
|
// when chunkSize is pinned. With totalFrames=30 and the default
|
|
// maxParallelChunks=16, the auto-sizer picks
|
|
// max(MIN_CHUNK_SIZE=10, ceil(30/16)=2) = 10 → ceil(30/10) = 3 chunks.
|
|
const planDir = join(runRoot, "plan-autosized");
|
|
mkdirSync(planDir, { recursive: true });
|
|
const result = await plan(
|
|
projectDir,
|
|
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
|
planDir,
|
|
);
|
|
expect(result.chunkCount).toBe(3);
|
|
const chunks = JSON.parse(
|
|
readFileSync(join(planDir, "meta", "chunks.json"), "utf-8"),
|
|
) as Array<{ index: number; startFrame: number; endFrame: number }>;
|
|
expect(chunks).toHaveLength(3);
|
|
// Encoder gopSize must follow the auto-sized chunk so chunk-boundary
|
|
// IDR keyframes still land at frame 0 of each chunk.
|
|
const encoder = JSON.parse(
|
|
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
|
) as Record<string, unknown>;
|
|
expect(encoder.gopSize).toBe(10);
|
|
expect(encoder.chunkSize).toBe(10);
|
|
},
|
|
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,
|
|
);
|
|
|
|
it(
|
|
"plan.json.planHash matches recomputePlanHashFromPlanDir(planDir) on the same disk",
|
|
async () => {
|
|
// Regression guard for a real-world bug observed on audio-bearing
|
|
// fixtures: plan() left a temporary `.plan-work/` subtree inside
|
|
// planDir while freezePlan walked it, so the hash baked into
|
|
// plan.json included artifacts the chunk worker would never see.
|
|
// The chunk worker's `recomputePlanHashFromPlanDir` walk then
|
|
// returned a different hash, tripping PLAN_HASH_MISMATCH at the
|
|
// first chunk invocation.
|
|
//
|
|
// This test verifies that the hash plan() writes matches the hash
|
|
// recomputed from the on-disk planDir contents — i.e. the chunk
|
|
// worker's view. Holds for any plan, audio or not.
|
|
const planDir = join(runRoot, "plan-hash-recompute");
|
|
mkdirSync(planDir, { recursive: true });
|
|
const result = await plan(
|
|
projectDir,
|
|
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
|
planDir,
|
|
);
|
|
const recomputed = recomputePlanHashFromPlanDir(planDir);
|
|
expect(recomputed).toBe(result.planHash);
|
|
const planJson = JSON.parse(readFileSync(join(planDir, "plan.json"), "utf-8")) as {
|
|
planHash: string;
|
|
protocol?: unknown;
|
|
};
|
|
expect(planJson.planHash).toBe(result.planHash);
|
|
expect(planJson.protocol).toEqual(CURRENT_PLAN_PROTOCOL);
|
|
|
|
delete planJson.protocol;
|
|
writeFileSync(join(planDir, "plan.json"), `${JSON.stringify(planJson, null, 2)}\n`, "utf-8");
|
|
expect(recomputePlanHashFromPlanDir(planDir)).toBe(result.planHash);
|
|
|
|
planJson.protocol = CURRENT_PLAN_PROTOCOL;
|
|
writeFileSync(join(planDir, "plan.json"), `${JSON.stringify(planJson, null, 2)}\n`, "utf-8");
|
|
expect(recomputePlanHashFromPlanDir(planDir)).toBe(result.planHash);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
// Audio-bearing variant of the planHash recompute test. The pre-fix bug
|
|
// surfaced because `runAudioStage` downloads/mixes source audio into
|
|
// `<planDir>/.plan-work/`, and `freezePlan` walked that subtree before
|
|
// plan.ts cleaned it up. A composition without `<audio>` short-circuits
|
|
// the audio stage and never materialises `.plan-work/downloads/`, so
|
|
// the no-audio test above would pass even on the broken code. This
|
|
// variant generates a 1s silent wav via `ffmpeg`, references it from
|
|
// the composition, and runs plan() — exercising the audio-mix path
|
|
// that produced the original bug.
|
|
const HAS_FFMPEG = (() => {
|
|
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
|
|
return spawnSync("ffmpeg", ["-version"]).status === 0;
|
|
})();
|
|
|
|
it.skipIf(!HAS_FFMPEG)(
|
|
"plan.json.planHash matches recompute on an audio-bearing composition",
|
|
async () => {
|
|
const audioProjectDir = join(runRoot, "project-with-audio");
|
|
mkdirSync(audioProjectDir, { recursive: true });
|
|
// Generate a 1s mono silent wav. PCM keeps the file tiny without
|
|
// pulling in an audio asset fixture.
|
|
const audioPath = join(audioProjectDir, "silence.wav");
|
|
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
|
|
const ffmpeg = spawnSync("ffmpeg", [
|
|
"-nostdin",
|
|
"-v",
|
|
"error",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"anullsrc=channel_layout=mono:sample_rate=44100",
|
|
"-t",
|
|
"1",
|
|
"-c:a",
|
|
"pcm_s16le",
|
|
audioPath,
|
|
]);
|
|
if (ffmpeg.status !== 0) {
|
|
throw new Error(`ffmpeg silence-wav generation failed: ${ffmpeg.stderr?.toString()}`);
|
|
}
|
|
writeFileSync(
|
|
join(audioProjectDir, "index.html"),
|
|
`<!doctype html>
|
|
<html><head><meta charset="utf-8"></head><body>
|
|
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">
|
|
<audio data-composition-audio src="silence.wav"></audio>
|
|
<p>audio-bearing fixture</p>
|
|
</div>
|
|
</body></html>`,
|
|
"utf-8",
|
|
);
|
|
|
|
const planDir = join(runRoot, "plan-hash-recompute-audio");
|
|
mkdirSync(planDir, { recursive: true });
|
|
const result = await plan(
|
|
audioProjectDir,
|
|
{ fps: 30, width: 320, height: 240, format: "mp4" },
|
|
planDir,
|
|
);
|
|
const recomputed = recomputePlanHashFromPlanDir(planDir);
|
|
// This assertion fails on the pre-fix code: freezePlan saw
|
|
// `.plan-work/downloads/` (or whatever the audio stage leaves
|
|
// behind) inside planDir, baked it into plan.json's planHash,
|
|
// and then plan.ts rm'd it — so recompute walks a different
|
|
// file set than freezePlan did.
|
|
expect(recomputed).toBe(result.planHash);
|
|
// Verify the audio stage actually fired (otherwise the test
|
|
// pins the wrong path — the same false-pass mode as the
|
|
// no-audio variant above).
|
|
expect(existsSync(join(planDir, "audio.aac"))).toBe(true);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("plan() — codec knob", () => {
|
|
const TIMEOUT_MS = 30_000;
|
|
|
|
it(
|
|
"defaults `codec` to h264 (libx264-software) for mp4",
|
|
async () => {
|
|
const planDir = join(runRoot, "plan-codec-default");
|
|
mkdirSync(planDir, { recursive: true });
|
|
await plan(projectDir, { fps: 30, width: 320, height: 240, format: "mp4" }, planDir);
|
|
const encoder = JSON.parse(
|
|
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
|
) as Record<string, unknown>;
|
|
expect(encoder.encoder).toBe("libx264-software");
|
|
expect(encoder.pixelFormat).toBe("yuv420p");
|
|
expect(encoder).not.toHaveProperty("vp9CpuUsed");
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
'maps `codec: "h265"` to libx265-software for mp4',
|
|
async () => {
|
|
const planDir = join(runRoot, "plan-codec-h265");
|
|
mkdirSync(planDir, { recursive: true });
|
|
await plan(
|
|
projectDir,
|
|
{ fps: 30, width: 320, height: 240, format: "mp4", codec: "h265" },
|
|
planDir,
|
|
);
|
|
const encoder = JSON.parse(
|
|
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 and
|
|
// 10-bit / HDR pixelFormat selection is not exposed on this surface.
|
|
expect(encoder.pixelFormat).toBe("yuv420p");
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it("rejects `codec` with format other than mp4", async () => {
|
|
const planDir = join(runRoot, "plan-codec-bad-format");
|
|
mkdirSync(planDir, { recursive: true });
|
|
let caught: unknown;
|
|
try {
|
|
await plan(
|
|
projectDir,
|
|
// @ts-expect-error — runtime check is the test's purpose.
|
|
{ fps: 30, width: 320, height: 240, format: "mov", codec: "h265" },
|
|
planDir,
|
|
);
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeInstanceOf(Error);
|
|
expect((caught as Error).message).toMatch(/codec.*only valid for format="mp4"/);
|
|
});
|
|
|
|
it("rejects unknown codec strings for format=mp4 (no silent fall-through to h264)", async () => {
|
|
const planDir = join(runRoot, "plan-codec-unknown");
|
|
mkdirSync(planDir, { recursive: true });
|
|
let caught: unknown;
|
|
try {
|
|
await plan(
|
|
projectDir,
|
|
// @ts-expect-error — runtime check is the test's purpose. Catches
|
|
// typos ("H265") and future codec additions ("av1") that a JS
|
|
// caller building config from JSON might pass.
|
|
{ fps: 30, width: 320, height: 240, format: "mp4", codec: "h266" },
|
|
planDir,
|
|
);
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeInstanceOf(Error);
|
|
expect((caught as Error).message).toMatch(/codec must be "h264" or "h265"/);
|
|
});
|
|
});
|
|
|
|
describe("plan() — variables", () => {
|
|
const TIMEOUT_MS = 30_000;
|
|
|
|
it(
|
|
"snapshots variables into meta/encoder.json so chunk workers see the controller's set",
|
|
async () => {
|
|
const planDir = join(runRoot, "plan-variables-snapshot");
|
|
mkdirSync(planDir, { recursive: true });
|
|
const variables = { title: "Hello", accent: "#ff0000" };
|
|
await plan(
|
|
projectDir,
|
|
{ fps: 30, width: 320, height: 240, format: "mp4", variables },
|
|
planDir,
|
|
);
|
|
const encoder = JSON.parse(
|
|
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
|
) as Record<string, unknown>;
|
|
expect(encoder.variables).toEqual(variables);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"omits the variables key from canonical encoder.json when the caller passes no variables",
|
|
async () => {
|
|
// Backwards compat: a no-variables plan must hash identically to
|
|
// pre-Phase-9 plans. Since the canonical encoder.json strips
|
|
// `undefined` values, the key must not appear at all.
|
|
const planDir = join(runRoot, "plan-variables-absent");
|
|
mkdirSync(planDir, { recursive: true });
|
|
await plan(projectDir, { fps: 30, width: 320, height: 240, format: "mp4" }, planDir);
|
|
const encoderRaw = readFileSync(join(planDir, "meta", "encoder.json"), "utf-8");
|
|
expect(encoderRaw).not.toContain("variables");
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"produces a DIFFERENT planHash when variables differ",
|
|
async () => {
|
|
// Variables fold into planHash via meta/encoder.json bytes. Two plans
|
|
// with different variables must produce different hashes — chunked
|
|
// output depends on the injected values, and the byte-identical-
|
|
// retry contract has to bind to the controller's choice.
|
|
const planDirA = join(runRoot, "plan-variables-different-a");
|
|
const planDirB = join(runRoot, "plan-variables-different-b");
|
|
mkdirSync(planDirA, { recursive: true });
|
|
mkdirSync(planDirB, { recursive: true });
|
|
|
|
const base = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
|
|
const a = await plan(projectDir, { ...base, variables: { title: "Alice" } }, planDirA);
|
|
const b = await plan(projectDir, { ...base, variables: { title: "Bob" } }, planDirB);
|
|
expect(a.planHash).not.toBe(b.planHash);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"produces the SAME planHash for two plans with the same variables",
|
|
async () => {
|
|
// Canonical-JSON sorts object keys, so the same variables (regardless
|
|
// of insertion order on the caller side) must round-trip to the
|
|
// same encoder.json bytes and therefore the same planHash.
|
|
const planDirA = join(runRoot, "plan-variables-same-a");
|
|
const planDirB = join(runRoot, "plan-variables-same-b");
|
|
mkdirSync(planDirA, { recursive: true });
|
|
mkdirSync(planDirB, { recursive: true });
|
|
|
|
const base = { fps: 30 as const, width: 320, height: 240, format: "mp4" as const };
|
|
const a = await plan(
|
|
projectDir,
|
|
{ ...base, variables: { title: "Alice", accent: "#ff0000" } },
|
|
planDirA,
|
|
);
|
|
const b = await plan(
|
|
projectDir,
|
|
// Same values, opposite insertion order.
|
|
{ ...base, variables: { accent: "#ff0000", title: "Alice" } },
|
|
planDirB,
|
|
);
|
|
expect(a.planHash).toBe(b.planHash);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"no-variables plan hashes identically to itself (backwards-compat baseline)",
|
|
async () => {
|
|
// Pin the no-variables path so adding the new field doesn't silently
|
|
// change the hash for callers who never opt in. Re-runs the
|
|
// determinism check from the golden block, scoped to the variables
|
|
// surface so a future refactor here trips a focused failure.
|
|
const planDirA = join(runRoot, "plan-no-variables-determinism-a");
|
|
const planDirB = join(runRoot, "plan-no-variables-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);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe("plan() — webm format (distributed VP9)", () => {
|
|
const TIMEOUT_MS = 30_000;
|
|
|
|
it(
|
|
'maps `format: "webm"` to libvpx-vp9-software + yuva420p',
|
|
async () => {
|
|
// Pins the plan-time encoder choice for webm: libvpx-vp9-software
|
|
// with yuva420p so the format's alpha-channel contract round-trips
|
|
// through chunked rendering.
|
|
const planDir = join(runRoot, "plan-webm-vp9");
|
|
mkdirSync(planDir, { recursive: true });
|
|
const result = await plan(
|
|
projectDir,
|
|
{ fps: 30, width: 320, height: 240, format: "webm" },
|
|
planDir,
|
|
);
|
|
expect(result.format).toBe("webm");
|
|
|
|
const encoder = JSON.parse(
|
|
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
|
) as Record<string, unknown>;
|
|
expect(encoder.encoder).toBe("libvpx-vp9-software");
|
|
expect(encoder.pixelFormat).toBe("yuva420p");
|
|
// Closed-GOP must be on so concat-copy at assemble time works.
|
|
// gopSize equals the chunkSize so every chunk's first frame is a
|
|
// keyframe with no alt-ref references reaching back across seams.
|
|
expect(encoder.closedGop).toBe(true);
|
|
expect(encoder.gopSize).toBe(encoder.chunkSize);
|
|
expect(encoder.vp9CpuUsed).toBe(4);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it(
|
|
"locks the resolved VP9 cpu-used value into encoder metadata",
|
|
async () => {
|
|
const planDir = join(runRoot, "plan-webm-vp9-cpu-used");
|
|
mkdirSync(planDir, { recursive: true });
|
|
await plan(
|
|
projectDir,
|
|
{
|
|
fps: 30,
|
|
width: 320,
|
|
height: 240,
|
|
format: "webm",
|
|
producerConfig: { vp9CpuUsed: 2 },
|
|
},
|
|
planDir,
|
|
);
|
|
|
|
const encoder = JSON.parse(
|
|
readFileSync(join(planDir, "meta", "encoder.json"), "utf-8"),
|
|
) as Record<string, unknown>;
|
|
expect(encoder.vp9CpuUsed).toBe(2);
|
|
},
|
|
TIMEOUT_MS,
|
|
);
|
|
|
|
it("rejects `codec` with format=webm", async () => {
|
|
// webm is always libvpx-vp9 — same shape as mov (always ProRes 4444).
|
|
// A JS caller building config from JSON who passes `codec: "vp8"` or
|
|
// `codec: "av1"` must hit a typed error, not silently encode VP9.
|
|
const planDir = join(runRoot, "plan-webm-bad-codec");
|
|
mkdirSync(planDir, { recursive: true });
|
|
let caught: unknown;
|
|
try {
|
|
await plan(
|
|
projectDir,
|
|
// @ts-expect-error — runtime check is the test's purpose.
|
|
{ fps: 30, width: 320, height: 240, format: "webm", codec: "vp8" },
|
|
planDir,
|
|
);
|
|
} catch (err) {
|
|
caught = err;
|
|
}
|
|
expect(caught).toBeInstanceOf(Error);
|
|
expect((caught as Error).message).toMatch(/codec.*only valid for format="mp4"/);
|
|
});
|
|
});
|