mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
Merge pull request #2671 from heygen-com/fix/bound-static-dedup-cardinality
fix: bound invalid render durations
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user