Files
hyperframes/packages/producer/src/services/render/planValidation.ts
T

175 lines
7.3 KiB
TypeScript

/**
* Plan-time validators for the distributed render pipeline. Each validator
* is invoked before freezing the plan, so banned configurations fail fast
* with a typed non-retryable error instead of being baked into a planDir
* and only surfacing on the chunk worker.
*/
import { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
import {
collectFontFamilyCustomProperties,
GENERIC_FAMILIES,
iterateFontFamilyDeclarations,
resolveFontFamilyDeclarationFamilies,
} from "../deterministicFonts.js";
/**
* Re-export the BROWSER_GPU_NOT_SOFTWARE code so distributed adapters and
* Step Functions / Temporal retry policies can match it without a
* cross-package import.
*/
export { BROWSER_GPU_NOT_SOFTWARE } from "@hyperframes/engine";
/**
* Re-export the shared font-family parser. The plan-time validator and the
* @font-face injector consume the same surface, so the parser lives next to
* the data.
*/
export { parseFontFamilyValue } from "../deterministicFonts.js";
/**
* Typed plan-validation error. Workflow adapters key retry policies off the
* `code` field to mark errors as non-retryable.
*/
export class PlanValidationError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "PlanValidationError";
this.code = code;
}
}
/**
* Subset of the merged plan / engine / render config that the GPU validator
* inspects. Both `useGpu` (RenderConfig) and `browserGpuMode` (EngineConfig)
* are optional so callers can pass any of the surrounding config shapes
* without an adapter layer.
*
* - `useGpu === true` → encoder GPU acceleration (NVENC/QSV/VAAPI). Banned
* because GPU encoders produce non-byte-identical output across machines.
* - `browserGpuMode !== "software"` → headless Chrome's WebGL is allowed
* to use hardware GL. Banned because hardware GL is bitwise unstable
* across drivers. Pairs with the runtime `assertSwiftShader` check that
* catches workers whose environment ignores Chrome's `--use-gl=swiftshader`.
*/
export interface ValidateNoGpuEncodeInput {
useGpu?: boolean;
browserGpuMode?: string;
}
/**
* Typed code for {@link validateNoSystemFonts}. Distributed chunk workers
* render in a Linux container without host-OS fonts; compositions declaring
* `-apple-system` / `system-ui` as a primary family would render differently
* on the worker, breaking byte-identical retries.
*/
export const SYSTEM_FONT_USED = "SYSTEM_FONT_USED";
/**
* Typed code for {@link validateDistributedDuration}. A duration this large
* almost always means an unbounded runtime timeline escaped into plan(),
* e.g. GSAP `repeat: -1` reporting its internal sentinel duration. Letting
* that reach chunk planning creates billions of frames and turns an authoring
* error into worker churn.
*/
export const DISTRIBUTED_DURATION_OUT_OF_RANGE = "DISTRIBUTED_DURATION_OUT_OF_RANGE";
/** Distributed renders are operationally bounded to one day of output. */
export const MAX_DISTRIBUTED_DURATION_SECONDS = 24 * 60 * 60;
/**
* Reject any config that would let GPU encode or hardware-GL slip into a
* distributed render. Throws {@link PlanValidationError} with
* `code === BROWSER_GPU_NOT_SOFTWARE` when either gate trips. The message
* names the offending field so the caller can surface a clean error.
*/
export function validateNoGpuEncode(config: ValidateNoGpuEncodeInput): void {
if (config.useGpu === true) {
throw new PlanValidationError(
BROWSER_GPU_NOT_SOFTWARE,
"[planValidation] GPU encode is banned in distributed mode: " +
"config.useGpu === true. " +
"Distributed retries must be byte-identical, but NVENC/QSV/VAAPI " +
"produce different output across machines. Set useGpu=false (the " +
"default) — software libx264/libx265 is the only supported encoder " +
"in distributed mode.",
);
}
if (config.browserGpuMode !== undefined && config.browserGpuMode !== "software") {
throw new PlanValidationError(
BROWSER_GPU_NOT_SOFTWARE,
`[planValidation] Hardware browser GPU is banned in distributed mode: ` +
`config.browserGpuMode === ${JSON.stringify(config.browserGpuMode)}. ` +
`Hardware GL is bitwise unstable across drivers. Set browserGpuMode="software" ` +
`so Chrome launches with --use-gl=swiftshader.`,
);
}
}
/**
* Reject a compiled HTML document whose top-priority font-family resolves to
* a host-OS / generic family. Throws {@link PlanValidationError} with
* `code === SYSTEM_FONT_USED` and the offending family in the message.
*
* Inspects the FIRST entry of each font-family declaration: that's the
* family the browser tries to use. Subsequent entries are CSS fallbacks,
* and a generic fallback is fine and conventional — so
* `font-family: "Inter", -apple-system, sans-serif` passes and
* `font-family: -apple-system, BlinkMacSystemFont, "Segoe UI"` fails.
*
* Reads font-family surfaces via `iterateFontFamilyDeclarations` so the
* @font-face injector and this validator scan the same regions.
*/
export function validateNoSystemFonts(compiledHtml: string): void {
const customProperties = collectFontFamilyCustomProperties(compiledHtml);
for (const { surface, declaration } of iterateFontFamilyDeclarations(compiledHtml)) {
const families = resolveFontFamilyDeclarationFamilies(declaration, customProperties);
if (families.length === 0) continue;
const primaryRaw = families[0]!;
// Unresolved var() primaries are left to the browser; resolved custom
// properties are checked above so common `--font: system-ui` aliases fail.
if (!GENERIC_FAMILIES.has(primaryRaw.toLowerCase())) continue;
throw new PlanValidationError(
SYSTEM_FONT_USED,
`[planValidation] Composition declares a host-OS / generic primary ${surface}: ` +
`${JSON.stringify(primaryRaw)} (full declaration: ${JSON.stringify(declaration.trim())}). ` +
`Distributed chunk workers render in a Linux container and cannot produce byte-identical ` +
`output for fonts that resolve to host system installations. Use a deterministic web font ` +
`(e.g. Inter, Montserrat, or another @fontsource family) as the primary family; generic ` +
`names like "sans-serif" / "-apple-system" / "system-ui" are only allowed as fallbacks.`,
);
}
}
export function validateDistributedDuration(input: {
duration: number;
totalFrames: number;
fps: number;
}): void {
const { duration, totalFrames, fps } = input;
const maxFrames = Math.ceil(MAX_DISTRIBUTED_DURATION_SECONDS * fps);
if (
Number.isFinite(duration) &&
duration > 0 &&
Number.isFinite(fps) &&
fps > 0 &&
Number.isSafeInteger(totalFrames) &&
totalFrames > 0 &&
totalFrames <= maxFrames
) {
return;
}
throw new PlanValidationError(
DISTRIBUTED_DURATION_OUT_OF_RANGE,
`[planValidation] Distributed render duration is out of range: ` +
`duration=${String(duration)}s totalFrames=${String(totalFrames)} fps=${String(fps)} ` +
`(maxDuration=${String(MAX_DISTRIBUTED_DURATION_SECONDS)}s, maxFrames=${String(maxFrames)}). ` +
`This usually means an unbounded timeline escaped into render planning, such as ` +
`GSAP repeat:-1 / yoyo loops without an explicit finite root duration. Add a finite ` +
`data-duration or replace infinite repeats with a finite repeat count before rendering.`,
);
}