mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(producer): reject impossible distributed durations (#1732)
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user