From 344d9c0a87aeba01beca618e21d2469921506cdf Mon Sep 17 00:00:00 2001 From: James Date: Tue, 21 Jul 2026 03:05:53 +0000 Subject: [PATCH 1/2] fix: bound invalid render durations --- docs/guides/claude-design-hyperframes.md | 2 +- .../claude-design-send-to-hyperframes.md | 2 +- ...ameCapture-staticDedupTimelineCall.test.ts | 42 ++++++++++++++++++- packages/engine/src/services/frameCapture.ts | 26 ++++++++++++ packages/lint/src/rules/gsap.test.ts | 39 +++++++++++++++++ packages/lint/src/rules/gsap.ts | 35 ++++++++++++++-- .../services/render/planValidation.test.ts | 15 +++++++ .../src/services/render/planValidation.ts | 33 +++++++++------ .../src/services/renderOrchestrator.ts | 6 +++ skills-manifest.json | 2 +- .../references/api-map.md | 2 +- .../references/limitations.md | 8 ++-- .../references/sequencing.md | 11 +++-- 13 files changed, 193 insertions(+), 30 deletions(-) diff --git a/docs/guides/claude-design-hyperframes.md b/docs/guides/claude-design-hyperframes.md index 86be031ca..f009be5dd 100644 --- a/docs/guides/claude-design-hyperframes.md +++ b/docs/guides/claude-design-hyperframes.md @@ -374,7 +374,7 @@ The skeleton handles most structural rules. These are the runtime rules the skel | `Math.random()` | Seeded PRNG (only if you need randomness) | | `Date.now()`, `performance.now()` | Hard-coded timing or `tl.time()` in `onUpdate` | | `setInterval`, `setTimeout` | Timeline tweens + `onUpdate` | -| `repeat: -1` | `repeat: Math.ceil(duration / cycle) - 1` | +| `repeat: -1` | `repeat: Math.max(0, Math.floor(duration / cycle) - 1)` | | `stagger: { from: "random" }` | `from: "start"`, `"center"`, `"end"` | | Async timeline construction | Synchronous at page load | diff --git a/docs/guides/claude-design-send-to-hyperframes.md b/docs/guides/claude-design-send-to-hyperframes.md index 3da76f64c..51d1133e9 100644 --- a/docs/guides/claude-design-send-to-hyperframes.md +++ b/docs/guides/claude-design-send-to-hyperframes.md @@ -371,7 +371,7 @@ The cloud renderer seeks the timeline frame-by-frame. Non-deterministic or self- | `Date.now()`, `performance.now()` | hard-coded timing or `tl.time()` in `onUpdate` | | `setInterval`, `setTimeout` | timeline tweens + `onUpdate` | | `requestAnimationFrame` | GSAP tweens | -| `repeat: -1` | `repeat: Math.ceil(duration / cycle) - 1` | +| `repeat: -1` | `repeat: Math.max(0, Math.floor(duration / cycle) - 1)` | | `stagger: { from: "random" }` | `from: "start"`, `"center"`, or `"end"` | | async timeline construction | build synchronously at page load | | `video.play()` / `audio.play()` | the framework owns playback | diff --git a/packages/engine/src/services/frameCapture-staticDedupTimelineCall.test.ts b/packages/engine/src/services/frameCapture-staticDedupTimelineCall.test.ts index bc14c54ec..4bfc3e674 100644 --- a/packages/engine/src/services/frameCapture-staticDedupTimelineCall.test.ts +++ b/packages/engine/src/services/frameCapture-staticDedupTimelineCall.test.ts @@ -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[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); + }); }); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index e630a1020..c6948fcba 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -2376,6 +2376,20 @@ async function computeClipBoundaryFrames(page: Page, fps: number): Promise 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(), + hasVideo, + hasCanvas, + hasNonGsapAnim, + tweenCount, + eligible: false, + reason: `static-dedup frame analysis limit (${MAX_STATIC_DEDUP_ANALYSIS_FRAMES})`, + }; + } const animated = new Set(); for (const { start, end } of intervals) { const lo = Math.max(0, Math.floor(start * fps)); diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index e00a16721..2365a60d3 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -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 = ` + +
+ + +`; + 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 = ` + +
+ + +`; + 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 = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 88e472bea..a014ed724 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1481,10 +1481,10 @@ export const gsapRules: LintRule[] = [ 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[] = [ "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[] = [ 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[] = []; diff --git a/packages/producer/src/services/render/planValidation.test.ts b/packages/producer/src/services/render/planValidation.test.ts index b45b62dfc..7d10520b7 100644 --- a/packages/producer/src/services/render/planValidation.test.ts +++ b/packages/producer/src/services/render/planValidation.test.ts @@ -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({ diff --git a/packages/producer/src/services/render/planValidation.ts b/packages/producer/src/services/render/planValidation.ts index 3cbd9af63..e335e5bc4 100644 --- a/packages/producer/src/services/render/planValidation.ts +++ b/packages/producer/src/services/render/planValidation.ts @@ -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); +} diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index cb9e3cdb1..e8d37dbf9 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -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; diff --git a/skills-manifest.json b/skills-manifest.json index 737c286b1..83e8c6c0e 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -66,7 +66,7 @@ "files": 26 }, "remotion-to-hyperframes": { - "hash": "c96bb2f0af9e1143", + "hash": "3a0e6c2affb9f74e", "files": 70 }, "slideshow": { diff --git a/skills/remotion-to-hyperframes/references/api-map.md b/skills/remotion-to-hyperframes/references/api-map.md index 23481216e..08ce72f6c 100644 --- a/skills/remotion-to-hyperframes/references/api-map.md +++ b/skills/remotion-to-hyperframes/references/api-map.md @@ -31,7 +31,7 @@ See [sequencing.md](sequencing.md) for nesting and stagger details. | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | `` | `
` | | `` + `` | siblings with sequential `data-start` values | -| `` | not a primitive — emit a custom GSAP `repeat: -1` loop with manual offset math | +| `` | not a primitive — emit a bounded GSAP repeat from the available duration | | `` | drop the wrapper; HF doesn't have running animation outside the seek-driven timeline so freeze is a no-op | ## Timing diff --git a/skills/remotion-to-hyperframes/references/limitations.md b/skills/remotion-to-hyperframes/references/limitations.md index e01980652..948b95e73 100644 --- a/skills/remotion-to-hyperframes/references/limitations.md +++ b/skills/remotion-to-hyperframes/references/limitations.md @@ -44,10 +44,10 @@ output but visually-identical video, so SSIM passes — just flag it. ``` -Loop with `repeat: -1` works for _visual_ repetition. If the looped -child has cross-iteration state (a counter, a randomness seed), HF -won't reproduce it identically per iteration. Bow out unless the -child is fully deterministic per-iteration. +A bounded GSAP repeat can reproduce _visual_ repetition when its finite count is derived +from the visible duration. If the looped child has cross-iteration state (a counter, a +randomness seed), HF won't reproduce it identically per iteration. Bow out unless the child +is fully deterministic per-iteration; never use `repeat: -1`. ### Remotion's `` with crossOrigin diff --git a/skills/remotion-to-hyperframes/references/sequencing.md b/skills/remotion-to-hyperframes/references/sequencing.md index c0de381c6..bca207cc8 100644 --- a/skills/remotion-to-hyperframes/references/sequencing.md +++ b/skills/remotion-to-hyperframes/references/sequencing.md @@ -153,11 +153,14 @@ For Remotion `` translations see [transitions.md](transitions. ``` -HF doesn't have a `` primitive. Translate to a GSAP timeline with -`repeat: -1`: +HF doesn't have a `` primitive. Translate it to a bounded GSAP timeline using +the time available at its insertion point: ```js -const spinTl = gsap.timeline({ paused: true, repeat: -1, repeatRefresh: false }); +const cycleDuration = 1; +const availableDuration = compositionDuration - 3; +const repeat = Math.max(0, Math.floor(availableDuration / cycleDuration) - 1); +const spinTl = gsap.timeline({ paused: true, repeat, repeatRefresh: false }); spinTl.to(spinner, { rotate: 360, duration: 1.0, ease: "none" }); // Embed in the main composition timeline at the right offset: mainTl.add(spinTl, 3); @@ -166,7 +169,7 @@ mainTl.add(spinTl, 3); This is fragile — Remotion's `` resets internal state every iteration, which GSAP repeat does too, but if the looped child has its own animation, you need to be careful that GSAP's `repeatRefresh` is on or off as needed. -For most simple "spin forever" cases this is fine. +The finite count is required because HyperFrames seeks a bounded composition frame-by-frame. ## `` From 52da8d3cdb6e2a5645fdb67c7278ad25b2009c52 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 21 Jul 2026 03:51:33 +0000 Subject: [PATCH 2/2] test: align distributed duration assertion --- .../producer/src/services/distributed/planSizeCap.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/producer/src/services/distributed/planSizeCap.test.ts b/packages/producer/src/services/distributed/planSizeCap.test.ts index 1116db968..749240747 100644 --- a/packages/producer/src/services/distributed/planSizeCap.test.ts +++ b/packages/producer/src/services/distributed/planSizeCap.test.ts @@ -25,6 +25,7 @@ import { PlanTooLargeError, plan, } from "./plan.js"; +import { DISTRIBUTED_DURATION_OUT_OF_RANGE } from "../render/planValidation.js"; const FIXTURE_HTML = ` @@ -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,