Merge pull request #2671 from heygen-com/fix/bound-static-dedup-cardinality

fix: bound invalid render durations
This commit is contained in:
James Russo
2026-07-21 00:37:12 -04:00
committed by GitHub
14 changed files with 196 additions and 31 deletions
@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { computeStaticFrameSet } from "./frameCapture.js";
import {
computeStaticFrameSet,
isStaticDedupFrameAnalysisSafe,
MAX_STATIC_DEDUP_ANALYSIS_FRAMES,
} from "./frameCapture.js";
/**
* Regression lock: a GSAP `tl.call()` disqualifies a composition from
@@ -65,4 +69,40 @@ describe("computeStaticFrameSet disqualifies a comp containing a tl.call()", ()
expect(result.eligible).toBe(true);
expect(result.staticFrameSet.size).toBeGreaterThan(0);
});
it("fails closed before allocating frame Sets for a sentinel-sized duration", async () => {
const page = {
evaluate: vi.fn().mockResolvedValueOnce({
intervals: [{ start: 0, end: 10_000_000_000 }],
tweenCount: 1,
duration: 10_000_000_000,
hasVideo: false,
hasCanvas: false,
hasNonGsapAnim: false,
hasUnresolvableClipStart: false,
hasTimelineCall: false,
}),
} as unknown as Parameters<typeof computeStaticFrameSet>[0];
const result = await computeStaticFrameSet(page, 30);
expect(result.eligible).toBe(false);
expect(result.reason).toContain("frame analysis limit");
expect(result.staticFrameSet.size).toBe(0);
// No clip-boundary scan: oversized metadata exits before frame-index work starts.
expect(page.evaluate).toHaveBeenCalledTimes(1);
});
});
describe("static-dedup frame analysis cardinality", () => {
it("accepts the configured boundary and rejects the next frame", () => {
expect(isStaticDedupFrameAnalysisSafe(MAX_STATIC_DEDUP_ANALYSIS_FRAMES)).toBe(true);
expect(isStaticDedupFrameAnalysisSafe(MAX_STATIC_DEDUP_ANALYSIS_FRAMES + 1)).toBe(false);
});
it("rejects non-finite, unsafe, and non-positive frame counts", () => {
expect(isStaticDedupFrameAnalysisSafe(Number.POSITIVE_INFINITY)).toBe(false);
expect(isStaticDedupFrameAnalysisSafe(Number.MAX_SAFE_INTEGER + 1)).toBe(false);
expect(isStaticDedupFrameAnalysisSafe(0)).toBe(false);
});
});
@@ -2376,6 +2376,20 @@ async function computeClipBoundaryFrames(page: Page, fps: number): Promise<Set<n
return frames;
}
// Static dedup is an optional optimization. Building frame-index Sets scales with the
// composition's declared duration, so malformed/sentinel durations must fail closed before
// allocating them. Normal capture and the producer's typed duration validation still proceed.
export const MAX_STATIC_DEDUP_ANALYSIS_FRAMES = 1_000_000;
export function isStaticDedupFrameAnalysisSafe(totalFrames: number): boolean {
return (
Number.isFinite(totalFrames) &&
Number.isSafeInteger(totalFrames) &&
totalFrames > 0 &&
totalFrames <= MAX_STATIC_DEDUP_ANALYSIS_FRAMES
);
}
/**
* Predict the dedupable (static) frame set from window.__timelines. A frame f (f>0) is
* static iff NEITHER f NOR f-1 falls inside any GSAP tween interval — content didn't
@@ -2518,6 +2532,18 @@ export async function computeStaticFrameSet(
hasTimelineCall: boolean;
};
const totalFrames = Math.max(1, Math.ceil(duration * fps));
if (!isStaticDedupFrameAnalysisSafe(totalFrames)) {
return {
totalFrames,
staticFrameSet: new Set<number>(),
hasVideo,
hasCanvas,
hasNonGsapAnim,
tweenCount,
eligible: false,
reason: `static-dedup frame analysis limit (${MAX_STATIC_DEDUP_ANALYSIS_FRAMES})`,
};
}
const animated = new Set<number>();
for (const { start, end } of intervals) {
const lo = Math.max(0, Math.floor(start * fps));
+39
View File
@@ -862,6 +862,7 @@ describe("GSAP rules", () => {
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("repeat: -1");
expect(finding?.fixHint).toContain("Math.max(0, Math.floor");
});
it("does not error on finite repeat values", async () => {
@@ -881,6 +882,44 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("warns when a computed finite repeat can fall through to GSAP's -1 sentinel", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
const duration = 0.5;
const cycleDuration = 1;
const tl = gsap.timeline({ paused: true, repeat: Math.floor(duration / cycleDuration) - 1 });
window.__timelines = { main: tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_repeat_floor_unclamped");
expect(finding?.severity).toBe("warning");
expect(finding?.fixHint).toContain("Math.max(0, Math.floor");
});
it("accepts a clamped computed finite repeat", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
const duration = 0.5;
const cycleDuration = 1;
const tl = gsap.timeline({
paused: true,
repeat: Math.max(0, Math.floor(duration / cycleDuration) - 1),
});
window.__timelines = { main: tl };
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "gsap_repeat_floor_unclamped");
expect(finding).toBeUndefined();
});
it("does not error on repeat: -1 inside JavaScript comments", async () => {
const html = `
<html><body>
+31 -4
View File
@@ -1481,10 +1481,10 @@ export const gsapRules: LintRule<LintContext>[] = [
message:
"GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " +
"capture engine which seeks to exact frame times. Use a finite repeat count calculated " +
"from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
"from the composition duration: `repeat: Math.max(0, Math.floor(duration / cycleDuration) - 1)`.",
fixHint:
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " +
"Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.",
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.max(0, Math.floor(totalDuration / singleCycleDuration) - 1)`. " +
"Use Math.floor (not Math.ceil) so the animation fits, and clamp at zero so a short composition cannot evaluate to -1.",
snippet: truncateSnippet(snippet),
});
}
@@ -1510,7 +1510,7 @@ export const gsapRules: LintRule<LintContext>[] = [
"For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
fixHint:
"Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " +
"`repeat: Math.floor(totalDuration / cycleDuration) - 1`. " +
"`repeat: Math.max(0, Math.floor(totalDuration / cycleDuration) - 1)`. " +
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet),
});
@@ -1518,6 +1518,33 @@ export const gsapRules: LintRule<LintContext>[] = [
return findings;
},
// gsap_repeat_floor_unclamped
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
// A direct floor-minus-one expression becomes GSAP's infinite -1 sentinel when
// the visible duration is shorter than one full cycle. Math.max-wrapped forms
// intentionally do not match because `repeat:` is followed by Math.max, not Math.floor.
const pattern = /repeat\s*:\s*Math\.floor\s*\([^)]+\)\s*-\s*1/g;
for (const { snippet } of scanScriptsForRegexMatches(scripts, pattern, {
stripComments: false,
contextBefore: 40,
contextAfter: 40,
})) {
findings.push({
code: "gsap_repeat_floor_unclamped",
severity: "warning",
message:
"GSAP repeat calculation can evaluate to -1 when the composition is shorter than one cycle, " +
"which GSAP interprets as an infinite repeat.",
fixHint:
"Clamp the finite repeat count at zero: " +
"`repeat: Math.max(0, Math.floor(totalDuration / cycleDuration) - 1)`.",
snippet: truncateSnippet(snippet),
});
}
return findings;
},
// scene_layer_missing_visibility_kill
({ scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
@@ -25,6 +25,7 @@ import {
PlanTooLargeError,
plan,
} from "./plan.js";
import { DISTRIBUTED_DURATION_OUT_OF_RANGE } from "../render/planValidation.js";
const FIXTURE_HTML = `<!doctype html>
<html><body>
@@ -192,8 +193,9 @@ describe("plan() duration guard", () => {
}
expect(caught).toBeInstanceOf(Error);
expect((caught as { code?: string }).code).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE);
expect(String((caught as Error).message)).toMatch(/duration/i);
expect(String((caught as Error).message)).toMatch(/distributed/i);
expect(String((caught as Error).message)).toMatch(/render/i);
expect(String((caught as Error).message)).toContain("300000000000");
},
TIMEOUT_MS,
@@ -11,10 +11,13 @@ import {
BROWSER_GPU_NOT_SOFTWARE,
DISTRIBUTED_DURATION_OUT_OF_RANGE,
MAX_DISTRIBUTED_DURATION_SECONDS,
MAX_RENDER_DURATION_SECONDS,
PlanValidationError,
RENDER_DURATION_OUT_OF_RANGE,
SYSTEM_FONT_USED,
parseFontFamilyValue,
validateDistributedDuration,
validateRenderDuration,
validateNoGpuEncode,
validateNoSystemFonts,
} from "./planValidation.js";
@@ -95,6 +98,18 @@ describe("validateNoGpuEncode", () => {
});
describe("validateDistributedDuration", () => {
it("keeps the generic validator and legacy distributed API behavior aligned", () => {
expect(MAX_RENDER_DURATION_SECONDS).toBe(MAX_DISTRIBUTED_DURATION_SECONDS);
expect(RENDER_DURATION_OUT_OF_RANGE).toBe(DISTRIBUTED_DURATION_OUT_OF_RANGE);
expect(() =>
validateRenderDuration({
duration: MAX_RENDER_DURATION_SECONDS,
totalFrames: MAX_RENDER_DURATION_SECONDS * 30,
fps: 30,
}),
).not.toThrow();
});
it("accepts a finite duration within the distributed ceiling", () => {
expect(() =>
validateDistributedDuration({
@@ -67,17 +67,15 @@ 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.
*/
/** Typed code for invalid duration metadata resolved by the shared browser probe. */
export const DISTRIBUTED_DURATION_OUT_OF_RANGE = "DISTRIBUTED_DURATION_OUT_OF_RANGE";
/** Generic alias; the legacy value remains stable for workflow retry policies. */
export const RENDER_DURATION_OUT_OF_RANGE = DISTRIBUTED_DURATION_OUT_OF_RANGE;
/** Distributed renders are operationally bounded to one day of output. */
/** All render paths are operationally bounded to one day of output. */
export const MAX_DISTRIBUTED_DURATION_SECONDS = 24 * 60 * 60;
/** Generic alias retained alongside the distributed public API. */
export const MAX_RENDER_DURATION_SECONDS = MAX_DISTRIBUTED_DURATION_SECONDS;
/**
* Reject any config that would let GPU encode or hardware-GL slip into a
@@ -143,13 +141,13 @@ export function validateNoSystemFonts(compiledHtml: string): void {
}
}
export function validateDistributedDuration(input: {
export function validateRenderDuration(input: {
duration: number;
totalFrames: number;
fps: number;
}): void {
const { duration, totalFrames, fps } = input;
const maxFrames = Math.ceil(MAX_DISTRIBUTED_DURATION_SECONDS * fps);
const maxFrames = Math.ceil(MAX_RENDER_DURATION_SECONDS * fps);
if (
Number.isFinite(duration) &&
duration > 0 &&
@@ -163,12 +161,21 @@ export function validateDistributedDuration(input: {
}
throw new PlanValidationError(
DISTRIBUTED_DURATION_OUT_OF_RANGE,
`[planValidation] Distributed render duration is out of range: ` +
RENDER_DURATION_OUT_OF_RANGE,
`[planValidation] 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)}). ` +
`(maxDuration=${String(MAX_RENDER_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.`,
);
}
/** Backward-compatible distributed entry point for existing adopters. */
export function validateDistributedDuration(input: {
duration: number;
totalFrames: number;
fps: number;
}): void {
validateRenderDuration(input);
}
@@ -147,6 +147,7 @@ import {
} from "./render/videoFrameCoverage.js";
import { runCompileStage } from "./render/stages/compileStage.js";
import { runProbeStage } from "./render/stages/probeStage.js";
import { validateRenderDuration } from "./render/planValidation.js";
import {
runExtractVideosStage,
shouldCopyExtractedFrames,
@@ -1971,6 +1972,11 @@ async function executeRenderPipeline(input: {
job.totalFrames = probeResult.totalFrames;
const totalFrames = probeResult.totalFrames;
captureTotalFrames = totalFrames;
validateRenderDuration({
duration: probeResult.duration,
totalFrames,
fps: fpsToNumber(job.config.fps),
});
perfStages.browserProbeMs = probeResult.browserProbeMs;
perfStages.compileMs = Date.now() - stage1Start;