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 -1
View File
@@ -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) | | `Math.random()` | Seeded PRNG (only if you need randomness) |
| `Date.now()`, `performance.now()` | Hard-coded timing or `tl.time()` in `onUpdate` | | `Date.now()`, `performance.now()` | Hard-coded timing or `tl.time()` in `onUpdate` |
| `setInterval`, `setTimeout` | Timeline tweens + `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"` | | `stagger: { from: "random" }` | `from: "start"`, `"center"`, `"end"` |
| Async timeline construction | Synchronous at page load | | Async timeline construction | Synchronous at page load |
@@ -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` | | `Date.now()`, `performance.now()` | hard-coded timing or `tl.time()` in `onUpdate` |
| `setInterval`, `setTimeout` | timeline tweens + `onUpdate` | | `setInterval`, `setTimeout` | timeline tweens + `onUpdate` |
| `requestAnimationFrame` | GSAP tweens | | `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"` | | `stagger: { from: "random" }` | `from: "start"`, `"center"`, or `"end"` |
| async timeline construction | build synchronously at page load | | async timeline construction | build synchronously at page load |
| `video.play()` / `audio.play()` | the framework owns playback | | `video.play()` / `audio.play()` | the framework owns playback |
@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from "vitest"; 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 * 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.eligible).toBe(true);
expect(result.staticFrameSet.size).toBeGreaterThan(0); 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; 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 * 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 * 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; hasTimelineCall: boolean;
}; };
const totalFrames = Math.max(1, Math.ceil(duration * fps)); 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>(); const animated = new Set<number>();
for (const { start, end } of intervals) { for (const { start, end } of intervals) {
const lo = Math.max(0, Math.floor(start * fps)); const lo = Math.max(0, Math.floor(start * fps));
+39
View File
@@ -862,6 +862,7 @@ describe("GSAP rules", () => {
expect(finding).toBeDefined(); expect(finding).toBeDefined();
expect(finding?.severity).toBe("error"); expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("repeat: -1"); expect(finding?.message).toContain("repeat: -1");
expect(finding?.fixHint).toContain("Math.max(0, Math.floor");
}); });
it("does not error on finite repeat values", async () => { it("does not error on finite repeat values", async () => {
@@ -881,6 +882,44 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined(); 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 () => { it("does not error on repeat: -1 inside JavaScript comments", async () => {
const html = ` const html = `
<html><body> <html><body>
+31 -4
View File
@@ -1481,10 +1481,10 @@ export const gsapRules: LintRule<LintContext>[] = [
message: message:
"GSAP tween uses `repeat: -1` (infinite). Infinite repeats break the deterministic " + "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 " + "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: fixHint:
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.floor(totalDuration / singleCycleDuration) - 1`. " + "Replace `repeat: -1` with a finite count, e.g. `repeat: Math.max(0, Math.floor(totalDuration / singleCycleDuration) - 1)`. " +
"Use Math.floor (not Math.ceil) to ensure the animation fits within the total duration.", "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), 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.", "For example, Math.ceil(10.5 / 2) - 1 = 5 repeats → 6 cycles × 2s = 12s, exceeding 10.5s.",
fixHint: fixHint:
"Use `Math.floor` instead of `Math.ceil` to ensure the animation fits within the duration: " + "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 ✓", "Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet), snippet: truncateSnippet(snippet),
}); });
@@ -1518,6 +1518,33 @@ export const gsapRules: LintRule<LintContext>[] = [
return findings; 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 // scene_layer_missing_visibility_kill
({ scripts, tags }) => { ({ scripts, tags }) => {
const findings: HyperframeLintFinding[] = []; const findings: HyperframeLintFinding[] = [];
@@ -25,6 +25,7 @@ import {
PlanTooLargeError, PlanTooLargeError,
plan, plan,
} from "./plan.js"; } from "./plan.js";
import { DISTRIBUTED_DURATION_OUT_OF_RANGE } from "../render/planValidation.js";
const FIXTURE_HTML = `<!doctype html> const FIXTURE_HTML = `<!doctype html>
<html><body> <html><body>
@@ -192,8 +193,9 @@ describe("plan() duration guard", () => {
} }
expect(caught).toBeInstanceOf(Error); 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(/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"); expect(String((caught as Error).message)).toContain("300000000000");
}, },
TIMEOUT_MS, TIMEOUT_MS,
@@ -11,10 +11,13 @@ import {
BROWSER_GPU_NOT_SOFTWARE, BROWSER_GPU_NOT_SOFTWARE,
DISTRIBUTED_DURATION_OUT_OF_RANGE, DISTRIBUTED_DURATION_OUT_OF_RANGE,
MAX_DISTRIBUTED_DURATION_SECONDS, MAX_DISTRIBUTED_DURATION_SECONDS,
MAX_RENDER_DURATION_SECONDS,
PlanValidationError, PlanValidationError,
RENDER_DURATION_OUT_OF_RANGE,
SYSTEM_FONT_USED, SYSTEM_FONT_USED,
parseFontFamilyValue, parseFontFamilyValue,
validateDistributedDuration, validateDistributedDuration,
validateRenderDuration,
validateNoGpuEncode, validateNoGpuEncode,
validateNoSystemFonts, validateNoSystemFonts,
} from "./planValidation.js"; } from "./planValidation.js";
@@ -95,6 +98,18 @@ describe("validateNoGpuEncode", () => {
}); });
describe("validateDistributedDuration", () => { 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", () => { it("accepts a finite duration within the distributed ceiling", () => {
expect(() => expect(() =>
validateDistributedDuration({ validateDistributedDuration({
@@ -67,17 +67,15 @@ export interface ValidateNoGpuEncodeInput {
*/ */
export const SYSTEM_FONT_USED = "SYSTEM_FONT_USED"; export const SYSTEM_FONT_USED = "SYSTEM_FONT_USED";
/** /** Typed code for invalid duration metadata resolved by the shared browser probe. */
* 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"; 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; 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 * 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; duration: number;
totalFrames: number; totalFrames: number;
fps: number; fps: number;
}): void { }): void {
const { duration, totalFrames, fps } = input; const { duration, totalFrames, fps } = input;
const maxFrames = Math.ceil(MAX_DISTRIBUTED_DURATION_SECONDS * fps); const maxFrames = Math.ceil(MAX_RENDER_DURATION_SECONDS * fps);
if ( if (
Number.isFinite(duration) && Number.isFinite(duration) &&
duration > 0 && duration > 0 &&
@@ -163,12 +161,21 @@ export function validateDistributedDuration(input: {
} }
throw new PlanValidationError( throw new PlanValidationError(
DISTRIBUTED_DURATION_OUT_OF_RANGE, RENDER_DURATION_OUT_OF_RANGE,
`[planValidation] Distributed render duration is out of range: ` + `[planValidation] Render duration is out of range: ` +
`duration=${String(duration)}s totalFrames=${String(totalFrames)} fps=${String(fps)} ` + `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 ` + `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 ` + `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.`, `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"; } from "./render/videoFrameCoverage.js";
import { runCompileStage } from "./render/stages/compileStage.js"; import { runCompileStage } from "./render/stages/compileStage.js";
import { runProbeStage } from "./render/stages/probeStage.js"; import { runProbeStage } from "./render/stages/probeStage.js";
import { validateRenderDuration } from "./render/planValidation.js";
import { import {
runExtractVideosStage, runExtractVideosStage,
shouldCopyExtractedFrames, shouldCopyExtractedFrames,
@@ -1971,6 +1972,11 @@ async function executeRenderPipeline(input: {
job.totalFrames = probeResult.totalFrames; job.totalFrames = probeResult.totalFrames;
const totalFrames = probeResult.totalFrames; const totalFrames = probeResult.totalFrames;
captureTotalFrames = totalFrames; captureTotalFrames = totalFrames;
validateRenderDuration({
duration: probeResult.duration,
totalFrames,
fps: fpsToNumber(job.config.fps),
});
perfStages.browserProbeMs = probeResult.browserProbeMs; perfStages.browserProbeMs = probeResult.browserProbeMs;
perfStages.compileMs = Date.now() - stage1Start; perfStages.compileMs = Date.now() - stage1Start;
+1 -1
View File
@@ -66,7 +66,7 @@
"files": 26 "files": 26
}, },
"remotion-to-hyperframes": { "remotion-to-hyperframes": {
"hash": "c96bb2f0af9e1143", "hash": "3a0e6c2affb9f74e",
"files": 70 "files": 70
}, },
"slideshow": { "slideshow": {
@@ -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">` | | `<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 | | `<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 | | `<Freeze frame={F}>` | drop the wrapper; HF doesn't have running animation outside the seek-driven timeline so freeze is a no-op |
## Timing ## Timing
@@ -44,10 +44,10 @@ output but visually-identical video, so SSIM passes — just flag it.
</Loop> </Loop>
``` ```
Loop with `repeat: -1` works for _visual_ repetition. If the looped A bounded GSAP repeat can reproduce _visual_ repetition when its finite count is derived
child has cross-iteration state (a counter, a randomness seed), HF from the visible duration. If the looped child has cross-iteration state (a counter, a
won't reproduce it identically per iteration. Bow out unless the randomness seed), HF won't reproduce it identically per iteration. Bow out unless the child
child is fully deterministic per-iteration. is fully deterministic per-iteration; never use `repeat: -1`.
### Remotion's `<Img>` with crossOrigin ### Remotion's `<Img>` with crossOrigin
@@ -153,11 +153,14 @@ For Remotion `<TransitionSeries>` translations see [transitions.md](transitions.
</Loop> </Loop>
``` ```
HF doesn't have a `<Loop>` primitive. Translate to a GSAP timeline with HF doesn't have a `<Loop>` primitive. Translate it to a bounded GSAP timeline using
`repeat: -1`: the time available at its insertion point:
```js ```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" }); spinTl.to(spinner, { rotate: 360, duration: 1.0, ease: "none" });
// Embed in the main composition timeline at the right offset: // Embed in the main composition timeline at the right offset:
mainTl.add(spinTl, 3); mainTl.add(spinTl, 3);
@@ -166,7 +169,7 @@ mainTl.add(spinTl, 3);
This is fragile — Remotion's `<Loop>` resets internal state every iteration, This is fragile — Remotion's `<Loop>` resets internal state every iteration,
which GSAP repeat does too, but if the looped child has its own animation, 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. 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.
## `<Freeze>` ## `<Freeze>`