mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
fix(producer): reject impossible distributed durations (#1732)
This commit is contained in:
@@ -95,3 +95,13 @@ export type {
|
|||||||
CompositionMetadataJson,
|
CompositionMetadataJson,
|
||||||
LockedRenderConfig,
|
LockedRenderConfig,
|
||||||
} from "./services/render/stages/freezePlan.js";
|
} 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,
|
writeFileSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { join, relative, sep } from "node:path";
|
import { join, relative, sep } from "node:path";
|
||||||
import { type CanvasResolution } from "@hyperframes/core";
|
import { type CanvasResolution, fpsToNumber } from "@hyperframes/core";
|
||||||
import {
|
import {
|
||||||
type EngineConfig,
|
type EngineConfig,
|
||||||
type VideoFrameFormat,
|
type VideoFrameFormat,
|
||||||
@@ -60,7 +60,11 @@ import {
|
|||||||
type PlanDimensions,
|
type PlanDimensions,
|
||||||
sha256Hex,
|
sha256Hex,
|
||||||
} from "../render/stages/planHash.js";
|
} 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 { snapshotRuntimeEnv } from "../render/runtimeEnvSnapshot.js";
|
||||||
import {
|
import {
|
||||||
buildSyntheticRenderJob,
|
buildSyntheticRenderJob,
|
||||||
@@ -853,6 +857,11 @@ export async function plan(
|
|||||||
job.duration = probeResult.duration;
|
job.duration = probeResult.duration;
|
||||||
job.totalFrames = probeResult.totalFrames;
|
job.totalFrames = probeResult.totalFrames;
|
||||||
const 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.fileServer) closeFileServerSafely(probeResult.fileServer, "plan", log);
|
||||||
if (probeResult.probeSession) {
|
if (probeResult.probeSession) {
|
||||||
// Close inside a try/catch — leaking a Chrome process here would mask
|
// 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,
|
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", () => {
|
it("exports the non-retryable error codes + classes", () => {
|
||||||
expect(distributedSubpath.PLAN_TOO_LARGE).toBe("PLAN_TOO_LARGE");
|
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(
|
expect(distributedSubpath.FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED).toBe(
|
||||||
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
||||||
);
|
);
|
||||||
@@ -51,6 +55,7 @@ describe("@hyperframes/producer/distributed (subpath)", () => {
|
|||||||
|
|
||||||
expect(typeof distributedSubpath.PlanTooLargeError).toBe("function");
|
expect(typeof distributedSubpath.PlanTooLargeError).toBe("function");
|
||||||
expect(typeof distributedSubpath.FormatNotSupportedInDistributedError).toBe("function");
|
expect(typeof distributedSubpath.FormatNotSupportedInDistributedError).toBe("function");
|
||||||
|
expect(typeof distributedSubpath.PlanValidationError).toBe("function");
|
||||||
expect(typeof distributedSubpath.RenderChunkValidationError).toBe("function");
|
expect(typeof distributedSubpath.RenderChunkValidationError).toBe("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,12 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import {
|
import {
|
||||||
BROWSER_GPU_NOT_SOFTWARE,
|
BROWSER_GPU_NOT_SOFTWARE,
|
||||||
|
DISTRIBUTED_DURATION_OUT_OF_RANGE,
|
||||||
|
MAX_DISTRIBUTED_DURATION_SECONDS,
|
||||||
PlanValidationError,
|
PlanValidationError,
|
||||||
SYSTEM_FONT_USED,
|
SYSTEM_FONT_USED,
|
||||||
parseFontFamilyValue,
|
parseFontFamilyValue,
|
||||||
|
validateDistributedDuration,
|
||||||
validateNoGpuEncode,
|
validateNoGpuEncode,
|
||||||
validateNoSystemFonts,
|
validateNoSystemFonts,
|
||||||
} from "./planValidation.js";
|
} 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", () => {
|
describe("parseFontFamilyValue", () => {
|
||||||
it("splits a comma-separated list and strips whitespace + quotes", () => {
|
it("splits a comma-separated list and strips whitespace + quotes", () => {
|
||||||
expect(parseFontFamilyValue(`"Inter", -apple-system, sans-serif`)).toEqual([
|
expect(parseFontFamilyValue(`"Inter", -apple-system, sans-serif`)).toEqual([
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ export interface ValidateNoGpuEncodeInput {
|
|||||||
*/
|
*/
|
||||||
export const SYSTEM_FONT_USED = "SYSTEM_FONT_USED";
|
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
|
* Reject any config that would let GPU encode or hardware-GL slip into a
|
||||||
* distributed render. Throws {@link PlanValidationError} with
|
* 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