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"),
+ `
+
+
+
+`,
+ "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.`,
+ );
+}