/** * 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.`, ); }