mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 04:18:01 +00:00
Address findings from a three-agent code-review pass over the Phase 4 stack:
- regression-harness: hoist `readdirSync` out of the per-checkpoint
failure-extraction loop (was running 20 redundant syscalls on every
failing png-sequence test). Drop redundant `existsSync` guards before
`mkdirSync(recursive: true)` and `rmSync(force: true)`. Replace the
three-deep ternary that built the output filename suffix with a
single `Record<format, ext>` lookup.
- regression-harness-distributed: flatten the `format === "mp4" ? {...} : {...}`
branching in the `plan()` call into a single config object with a
conditional spread. `plan()` already accepts `codec: undefined` for
non-mp4 formats, so the duplicate object was unnecessary.
- chunkBoundary.test: rename the stale "byte-identical mp4" test title
to "byte-identical frames" (the test now uses png-sequence). Trim the
10-line comment justifying `rejectOnSystemFonts: false` to the
essential WHY.
- renderChunk / plan.test / regression-harness: drop trailing-edge
comment phrases that pinned the prose to the PR's calendar context
("today", "v1.5", "pre-codec-knob output", section-numbered cross-
references to the planning doc).
No behavior change. All 49 distributed unit tests pass. Smoke + four
distributed format fixtures pass in --mode=distributed-simulated.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
230 lines
9.9 KiB
TypeScript
230 lines
9.9 KiB
TypeScript
/**
|
||
* Distributed-render path for the regression harness.
|
||
*
|
||
* The regression harness has two modes:
|
||
*
|
||
* - `in-process` (default) — calls `executeRenderJob`, the same path the
|
||
* `hyperframes render` CLI takes. This is what produced every existing
|
||
* `tests/<name>/output/output.mp4` golden baseline.
|
||
*
|
||
* - `distributed-simulated` — calls `plan()` → `renderChunk()` per chunk
|
||
* → `assemble()` from `@hyperframes/producer/distributed`. No Temporal
|
||
* or Lambda involvement: the controller and chunk worker are both this
|
||
* process, but they go through the same artifact (planDir + frozen
|
||
* `meta/encoder.json` + per-chunk concat-copy) that a real fan-out
|
||
* would.
|
||
*
|
||
* Both modes share the per-fixture `minPsnr` threshold — distributed must
|
||
* pass the same quality bar the in-process renderer passes against the
|
||
* same frozen baseline. A separate {@link DISTRIBUTED_SIMULATED_MIN_PSNR_DB}
|
||
* pathology floor catches the case where a fixture authored a permissive
|
||
* threshold and distributed regresses to fully-black output. The §5.1
|
||
* 50 dB target was written for per-render comparison (fresh in-process vs
|
||
* fresh distributed); against the frozen baseline file it's unreachable
|
||
* for either mode due to shared encoder/JPEG-capture jitter, so the
|
||
* harness can't use it as a per-test gate.
|
||
*
|
||
* Not every fixture can run in distributed-simulated mode. Distributed mode
|
||
* refuses webm, HDR mp4, NTSC framerates, and non-{24,30,60} fps at plan
|
||
* time. Fixtures that don't meet the constraints are skipped — the harness
|
||
* logs the reason and the fixture is treated as "passed (skipped)" in
|
||
* distributed-simulated mode.
|
||
*/
|
||
|
||
import { existsSync, mkdirSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import type { Fps } from "@hyperframes/core";
|
||
import { assemble, plan, renderChunk } from "./distributed.js";
|
||
|
||
/** Two-mode contract that backs `--mode=<value>` on the regression harness CLI. */
|
||
export type HarnessMode = "in-process" | "distributed-simulated";
|
||
|
||
/**
|
||
* Absolute pathology floor for `--mode=distributed-simulated` — catches
|
||
* a chunk that renders fully-black against a fixture authored with a
|
||
* permissive `minPsnr`. Non-pathological drift is caught by the fixture's
|
||
* own threshold; both modes share the same encoder/JPEG-capture jitter
|
||
* floor against the frozen baseline file, so the §5.1 50 dB target is
|
||
* unreachable for either mode and isn't a useful per-test gate.
|
||
*/
|
||
export const DISTRIBUTED_SIMULATED_MIN_PSNR_DB = 10;
|
||
|
||
/** Result of {@link checkDistributedSupport}. */
|
||
export type DistributedSupportResult = { supported: true } | { supported: false; reason: string };
|
||
|
||
/**
|
||
* Decide whether a fixture's `renderConfig` is one the distributed pipeline
|
||
* can actually run. The four hard gates:
|
||
*
|
||
* - fps must be `{ num: 24|30|60, den: 1 }`. `DistributedRenderConfig.fps`
|
||
* accepts only the three integer values, and rationals like
|
||
* `{ num: 30000, den: 1001 }` (NTSC) trip the type system at the call
|
||
* site. We surface this gate in code rather than only in TS so the
|
||
* harness can skip the fixture cleanly instead of throwing.
|
||
* - format must not be `webm`. `plan()` refuses webm with
|
||
* `FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED`.
|
||
* - hdr must not be `true`. Distributed mode is SDR-only at v1.
|
||
*
|
||
* Callers that want the structured reason can read it off the returned
|
||
* `reason` field; the message is intended to be log-friendly.
|
||
*/
|
||
export function checkDistributedSupport(renderConfig: {
|
||
fps: Fps;
|
||
format?: "mp4" | "webm" | "mov" | "png-sequence";
|
||
hdr?: boolean;
|
||
}): DistributedSupportResult {
|
||
if (renderConfig.fps.den !== 1) {
|
||
return {
|
||
supported: false,
|
||
reason: `non-integer fps ${renderConfig.fps.num}/${renderConfig.fps.den} (distributed mode requires fps.den=1)`,
|
||
};
|
||
}
|
||
const fpsNum = renderConfig.fps.num;
|
||
if (fpsNum !== 24 && fpsNum !== 30 && fpsNum !== 60) {
|
||
return {
|
||
supported: false,
|
||
reason: `fps ${fpsNum} not in {24, 30, 60} (DistributedRenderConfig.fps is a closed set)`,
|
||
};
|
||
}
|
||
const format = renderConfig.format ?? "mp4";
|
||
if (format === "webm") {
|
||
return {
|
||
supported: false,
|
||
reason: "format=webm refused in distributed mode (VP9+matroska concat-copy is unstable)",
|
||
};
|
||
}
|
||
if (renderConfig.hdr === true) {
|
||
return {
|
||
supported: false,
|
||
reason: "hdr=true refused in distributed mode (HDR signaling re-apply not implemented)",
|
||
};
|
||
}
|
||
return { supported: true };
|
||
}
|
||
|
||
/**
|
||
* Inputs for {@link runDistributedSimulatedRender}. The harness has already
|
||
* prepared `projectDir` (a working copy of the fixture's `src/` directory)
|
||
* and `tempRoot` (where the harness writes its scratch artifacts).
|
||
*/
|
||
export interface RunDistributedSimulatedInput {
|
||
/** Working copy of the fixture's `src/` — contains `index.html`. */
|
||
projectDir: string;
|
||
/** Scratch root for plan + chunks; must be a directory the harness owns. */
|
||
tempRoot: string;
|
||
/** Where to write the assembled final mp4 / mov / png-sequence directory. */
|
||
renderedOutputPath: string;
|
||
/** From the fixture's renderConfig — must pass `checkDistributedSupport`. */
|
||
fps: 24 | 30 | 60;
|
||
format: "mp4" | "mov" | "png-sequence";
|
||
/**
|
||
* Codec for `format: "mp4"`. Defaults to `"h264"`; pass `"h265"` to
|
||
* exercise the libx265 closed-GOP path. Ignored for non-mp4 formats —
|
||
* `plan()` throws if codec is passed with a non-mp4 format.
|
||
*/
|
||
codec?: "h264" | "h265";
|
||
/** Optional chunkSize override; defaults to the plan's 240. */
|
||
chunkSize?: number;
|
||
/** Optional maxParallelChunks override; defaults to the plan's 16. */
|
||
maxParallelChunks?: number;
|
||
/** Forwarded to `plan()` and re-applied by `renderChunk()` at boot. */
|
||
variables?: Record<string, unknown>;
|
||
}
|
||
|
||
/**
|
||
* Run the distributed pipeline against a single fixture as if a fan-out
|
||
* adapter were driving it. The three activities run serially in this
|
||
* process — there is no Temporal, no Lambda, no S3 — so the planDir,
|
||
* chunk outputs, and assembled output all live under `tempRoot`.
|
||
*
|
||
* Width and height are required by `DistributedRenderConfig` for cross-call
|
||
* sanity but are not consulted at render time — `plan()` reads the
|
||
* composition's `data-width` / `data-height` attributes and overrides
|
||
* whatever the config carried. The harness passes a dummy 1920×1080 here
|
||
* for that reason; if the contract ever changes, the fixture's authored
|
||
* dimensions will flow through `PlanResult` and we can switch to using
|
||
* those instead.
|
||
*/
|
||
export async function runDistributedSimulatedRender(
|
||
input: RunDistributedSimulatedInput,
|
||
): Promise<void> {
|
||
const planDir = join(input.tempRoot, "plan");
|
||
const chunksDir = join(input.tempRoot, "chunks");
|
||
mkdirSync(planDir, { recursive: true });
|
||
mkdirSync(chunksDir, { recursive: true });
|
||
|
||
// 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,
|
||
{
|
||
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,
|
||
);
|
||
|
||
// Step B: render every chunk. Sequential to keep the harness predictable —
|
||
// adapters in production are free to fan out; this code path's job is to
|
||
// exercise the per-chunk activity itself.
|
||
const chunkPaths: string[] = [];
|
||
for (let i = 0; i < planResult.chunkCount; i++) {
|
||
const chunkPath =
|
||
input.format === "png-sequence"
|
||
? join(chunksDir, `chunk-${String(i).padStart(4, "0")}`)
|
||
: join(chunksDir, `chunk-${String(i).padStart(4, "0")}.${input.format}`);
|
||
await renderChunk(planDir, i, chunkPath);
|
||
chunkPaths.push(chunkPath);
|
||
}
|
||
|
||
// Step C: assemble. `audio.aac` only exists when the composition has
|
||
// audio — pass null otherwise so `assemble()` doesn't try to mux silence.
|
||
const audioPath = join(planDir, "audio.aac");
|
||
const audioForAssemble = existsSync(audioPath) ? audioPath : null;
|
||
await assemble(planDir, chunkPaths, audioForAssemble, input.renderedOutputPath);
|
||
}
|
||
|
||
/**
|
||
* Pick the PSNR threshold for a fixture given the harness mode. Both modes
|
||
* share the fixture's authored `minPsnr` — distributed must clear the same
|
||
* quality bar in-process clears against the same frozen baseline.
|
||
* Distributed-simulated additionally lifts the threshold to
|
||
* {@link DISTRIBUTED_SIMULATED_MIN_PSNR_DB} for fixtures with a permissive
|
||
* authored threshold; that absolute floor catches fully-black-output
|
||
* regressions independent of fixture tolerance.
|
||
*/
|
||
export function resolveMinPsnrForMode(mode: HarnessMode, fixtureMinPsnr: number): number {
|
||
if (mode === "in-process") return fixtureMinPsnr;
|
||
return Math.max(fixtureMinPsnr, DISTRIBUTED_SIMULATED_MIN_PSNR_DB);
|
||
}
|
||
|
||
/**
|
||
* Parse `--mode=<value>` from a single CLI token. Returns the parsed mode
|
||
* when the token matches the expected shape, `null` otherwise so the
|
||
* caller can pass the token through to the next handler. Throws on a
|
||
* known prefix with a bad value (`--mode=foo`) — surfacing a typo at
|
||
* parse time is cheaper than discovering at render time.
|
||
*/
|
||
export function parseHarnessModeFlag(token: string): HarnessMode | null {
|
||
if (token === "--mode=in-process") return "in-process";
|
||
if (token === "--mode=distributed-simulated") return "distributed-simulated";
|
||
if (token.startsWith("--mode=")) {
|
||
const value = token.slice("--mode=".length);
|
||
throw new Error(
|
||
`regression-harness: --mode must be 'in-process' or 'distributed-simulated' (got ${JSON.stringify(value)})`,
|
||
);
|
||
}
|
||
return null;
|
||
}
|