diff --git a/packages/producer/src/distributed.ts b/packages/producer/src/distributed.ts index 12343ddb14..25db28ca88 100644 --- a/packages/producer/src/distributed.ts +++ b/packages/producer/src/distributed.ts @@ -95,3 +95,13 @@ export type { CompositionMetadataJson, LockedRenderConfig, } from "./services/render/stages/freezePlan.js"; + +// ── Plan-time validation errors ──────────────────────────────────────────── +// Export typed deterministic validation codes so orchestration adapters can +// mark authoring/configuration failures as terminal while still retrying real +// infrastructure faults. +export { + DISTRIBUTED_DURATION_OUT_OF_RANGE, + MAX_DISTRIBUTED_DURATION_SECONDS, + PlanValidationError, +} from "./services/render/planValidation.js"; diff --git a/packages/producer/src/services/distributed/plan.ts b/packages/producer/src/services/distributed/plan.ts index ce8c24fbf9..eb4c64fc10 100644 --- a/packages/producer/src/services/distributed/plan.ts +++ b/packages/producer/src/services/distributed/plan.ts @@ -35,7 +35,7 @@ import { writeFileSync, } from "node:fs"; import { join, relative, sep } from "node:path"; -import { type CanvasResolution } from "@hyperframes/core"; +import { type CanvasResolution, fpsToNumber } from "@hyperframes/core"; import { type EngineConfig, type VideoFrameFormat, @@ -60,7 +60,11 @@ import { type PlanDimensions, sha256Hex, } from "../render/stages/planHash.js"; -import { validateNoGpuEncode, validateNoSystemFonts } from "../render/planValidation.js"; +import { + validateDistributedDuration, + validateNoGpuEncode, + validateNoSystemFonts, +} from "../render/planValidation.js"; import { snapshotRuntimeEnv } from "../render/runtimeEnvSnapshot.js"; import { buildSyntheticRenderJob, @@ -853,6 +857,11 @@ export async function plan( job.duration = probeResult.duration; job.totalFrames = probeResult.totalFrames; const totalFrames = probeResult.totalFrames; + validateDistributedDuration({ + duration: probeResult.duration, + totalFrames, + fps: fpsToNumber(job.config.fps), + }); if (probeResult.fileServer) closeFileServerSafely(probeResult.fileServer, "plan", log); if (probeResult.probeSession) { // Close inside a try/catch — leaking a Chrome process here would mask diff --git a/packages/producer/src/services/distributed/planSizeCap.test.ts b/packages/producer/src/services/distributed/planSizeCap.test.ts index 865c70e4a2..1116db9689 100644 --- a/packages/producer/src/services/distributed/planSizeCap.test.ts +++ b/packages/producer/src/services/distributed/planSizeCap.test.ts @@ -144,3 +144,58 @@ describe("plan() PLAN_TOO_LARGE throw path", () => { TIMEOUT_MS, ); }); + +describe("plan() duration guard", () => { + const TIMEOUT_MS = 30_000; + + it( + "rejects probe durations that would create impossible distributed frame counts", + async () => { + const projectDir = mkdtempSync(join(runRoot, "project-impossible-duration-")); + writeFileSync( + join(projectDir, "index.html"), + ` + +
+
hello
+
+ +`, + "utf-8", + ); + const planDir = mkdtempSync(join(runRoot, "plandir-impossible-duration-")); + + let caught: unknown; + try { + await plan( + projectDir, + { + fps: 30, + width: 320, + height: 240, + format: "mp4", + }, + planDir, + ); + } catch (err) { + caught = err; + } + + expect(caught).toBeInstanceOf(Error); + expect(String((caught as Error).message)).toMatch(/duration/i); + expect(String((caught as Error).message)).toMatch(/distributed/i); + expect(String((caught as Error).message)).toContain("300000000000"); + }, + TIMEOUT_MS, + ); +}); diff --git a/packages/producer/src/services/distributed/publicExports.test.ts b/packages/producer/src/services/distributed/publicExports.test.ts index 08ea9d350b..004ba4fce6 100644 --- a/packages/producer/src/services/distributed/publicExports.test.ts +++ b/packages/producer/src/services/distributed/publicExports.test.ts @@ -43,6 +43,10 @@ describe("@hyperframes/producer/distributed (subpath)", () => { it("exports the non-retryable error codes + classes", () => { expect(distributedSubpath.PLAN_TOO_LARGE).toBe("PLAN_TOO_LARGE"); + expect(distributedSubpath.DISTRIBUTED_DURATION_OUT_OF_RANGE).toBe( + "DISTRIBUTED_DURATION_OUT_OF_RANGE", + ); + expect(distributedSubpath.MAX_DISTRIBUTED_DURATION_SECONDS).toBe(24 * 60 * 60); expect(distributedSubpath.FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED).toBe( "FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED", ); @@ -51,6 +55,7 @@ describe("@hyperframes/producer/distributed (subpath)", () => { expect(typeof distributedSubpath.PlanTooLargeError).toBe("function"); expect(typeof distributedSubpath.FormatNotSupportedInDistributedError).toBe("function"); + expect(typeof distributedSubpath.PlanValidationError).toBe("function"); expect(typeof distributedSubpath.RenderChunkValidationError).toBe("function"); }); diff --git a/packages/producer/src/services/render/planValidation.test.ts b/packages/producer/src/services/render/planValidation.test.ts index 74e4abebe3..c08a65663e 100644 --- a/packages/producer/src/services/render/planValidation.test.ts +++ b/packages/producer/src/services/render/planValidation.test.ts @@ -9,9 +9,12 @@ import { describe, expect, it } from "bun:test"; import { BROWSER_GPU_NOT_SOFTWARE, + DISTRIBUTED_DURATION_OUT_OF_RANGE, + MAX_DISTRIBUTED_DURATION_SECONDS, PlanValidationError, SYSTEM_FONT_USED, parseFontFamilyValue, + validateDistributedDuration, validateNoGpuEncode, validateNoSystemFonts, } from "./planValidation.js"; @@ -91,6 +94,53 @@ describe("validateNoGpuEncode", () => { }); }); +describe("validateDistributedDuration", () => { + it("accepts a finite duration within the distributed ceiling", () => { + expect(() => + validateDistributedDuration({ + duration: MAX_DISTRIBUTED_DURATION_SECONDS, + totalFrames: MAX_DISTRIBUTED_DURATION_SECONDS * 30, + fps: 30, + }), + ).not.toThrow(); + }); + + it("throws DISTRIBUTED_DURATION_OUT_OF_RANGE for the engine's infinite-timeline sentinel", () => { + let caught: unknown; + try { + validateDistributedDuration({ + duration: 10_000_000_000, + totalFrames: 300_000_000_000, + fps: 30, + }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(PlanValidationError); + expect((caught as PlanValidationError).code).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE); + expect((caught as Error).message).toContain("300000000000"); + expect((caught as Error).message).toContain("GSAP repeat:-1"); + }); + + it("throws DISTRIBUTED_DURATION_OUT_OF_RANGE for non-finite or zero values", () => { + for (const input of [ + { duration: Number.POSITIVE_INFINITY, totalFrames: 1, fps: 30 }, + { duration: 0, totalFrames: 1, fps: 30 }, + { duration: 1, totalFrames: 0, fps: 30 }, + { duration: 1, totalFrames: 1, fps: Number.NaN }, + ]) { + let caught: unknown; + try { + validateDistributedDuration(input); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(PlanValidationError); + expect((caught as PlanValidationError).code).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE); + } + }); +}); + describe("parseFontFamilyValue", () => { it("splits a comma-separated list and strips whitespace + quotes", () => { expect(parseFontFamilyValue(`"Inter", -apple-system, sans-serif`)).toEqual([ diff --git a/packages/producer/src/services/render/planValidation.ts b/packages/producer/src/services/render/planValidation.ts index 1d0a4c0624..2f4078a328 100644 --- a/packages/producer/src/services/render/planValidation.ts +++ b/packages/producer/src/services/render/planValidation.ts @@ -62,6 +62,18 @@ export interface ValidateNoGpuEncodeInput { */ 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 @@ -124,3 +136,33 @@ export function validateNoSystemFonts(compiledHtml: string): void { ); } } + +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.`, + ); +}