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
2 changes: 1 addition & 1 deletion docs/guides/claude-design-hyperframes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/claude-design-send-to-hyperframes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
});
});
26 changes: 26 additions & 0 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down
39 changes: 39 additions & 0 deletions packages/lint/src/rules/gsap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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>
Expand Down
35 changes: 31 additions & 4 deletions packages/lint/src/rules/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
Expand All @@ -1510,14 +1510,41 @@ 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),
});
}
return findings;
},

// gsap_repeat_floor_unclamped
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
// A direct floor-minus-one expression becomes GSAP's infinite -1 sentinel when

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit — regex only catches inline repeat: Math.floor(...) - 1, not the variable-assigned form. \brepeat\s*:\s*Math\.floor requires Math.floor to sit directly after repeat:. So the equivalent pattern via a local variable — const count = Math.floor(duration / cycle) - 1; gsap.timeline({ repeat: count }); — is invisible to the rule. Not blocking (the direct-write form is by far the most common), but if HF authors reach for that idiom the warning never fires. Worth either (a) a follow-up rule that tracks the Math.floor(...) - 1 binding and its use in repeat:, or (b) a rule-doc note saying the check is inline-form-only so authors don't assume the linter covers all paths. — Rames D Jusso

// 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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question — warning severity for a pattern that can reach the deterministic-capture-breaking -1 sentinel? repeat: -1 fires as error on the same rule module, and this pattern can evaluate to the same value at runtime when duration < cycleDuration. Warning is defensible if the intent is «this is a smell, not a guaranteed bug» (only bites when the composition is short enough), but if downstream CI thresholds swallow warnings, an author landing this pattern in a short composition gets no signal. Was warning an explicit choice over error, or defaulting because the pattern only sometimes evaluates to -1? — Rames D Jusso

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[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions packages/producer/src/services/render/planValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
33 changes: 20 additions & 13 deletions packages/producer/src/services/render/planValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand All @@ -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)}). ` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — this message change breaks a pre-existing integration test. The generalization from "Distributed render duration is out of range: …" to "Render duration is out of range: …" drops the word Distributed that packages/producer/src/services/distributed/planSizeCap.test.ts:196 asserts on: expect(String((caught as Error).message)).toMatch(/distributed/i);. That assertion runs under bun run producer:test:integration and is what turned the «Producer: integration tests» CI job red. The unit-test suite (planValidation.test.ts) that the PR body notes as passing doesn't exercise this assertion. Smallest fix: update planSizeCap.test.ts:196 to /render duration/i (matches the new message and stays specific). If keeping the legacy wording is preferred, revert the message body change instead — but the current state is the failing intermediate. — Rames D Jusso

`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);
}
6 changes: 6 additions & 0 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"files": 26
},
"remotion-to-hyperframes": {
"hash": "c96bb2f0af9e1143",
"hash": "3a0e6c2affb9f74e",
"files": 70
},
"slideshow": {
Expand Down
2 changes: 1 addition & 1 deletion skills/remotion-to-hyperframes/references/api-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ See [sequencing.md](sequencing.md) for nesting and stagger details.
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `<Sequence from={F} durationInFrames={D}>` | `<div data-start="<F/fps>" data-duration="<D/fps>" data-track-index="N">` |
| `<Series>` + `<Series.Sequence>` | siblings with sequential `data-start` values |
| `<Loop durationInFrames={D}>` | not a primitive — emit a custom GSAP `repeat: -1` loop with manual offset math |
| `<Loop durationInFrames={D}>` | not a primitive — emit a bounded GSAP repeat from the available duration |
| `<Freeze frame={F}>` | drop the wrapper; HF doesn't have running animation outside the seek-driven timeline so freeze is a no-op |

## Timing
Expand Down
8 changes: 4 additions & 4 deletions skills/remotion-to-hyperframes/references/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ output but visually-identical video, so SSIM passes — just flag it.
</Loop>
```

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 `<Img>` with crossOrigin

Expand Down
Loading
Loading