Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/producer/src/distributed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
13 changes: 11 additions & 2 deletions packages/producer/src/services/distributed/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions packages/producer/src/services/distributed/planSizeCap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
`<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="240" data-start="0">
<div class="caption">hello</div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines.root = {
duration() { return 10000000000; },
pause() {},
time() { return 0; },
seek() {},
totalTime() {},
add() {}
};
</script>
</body></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,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand All @@ -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");
});

Expand Down
50 changes: 50 additions & 0 deletions packages/producer/src/services/render/planValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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([
Expand Down
42 changes: 42 additions & 0 deletions packages/producer/src/services/render/planValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.`,
);
}
Loading