fix(player): handle Infinity duration; add lint rules for data-duration and Math.ceil overshoot (#243)

* fix(player): handle Infinity duration from runtime gracefully

When compositions have repeating animations without data-duration, the
runtime sends durationInFrames: Infinity. The player now ignores
non-finite duration values instead of displaying "Infinity:NaN" in the
controls. formatTime also returns "0:00" for non-finite inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(lint): add data-duration and Math.ceil overshoot rules

- Add root_composition_missing_data_duration warning when the root
  composition element is missing data-duration, which causes the runtime
  to infer Infinity for loop-inflated timelines.
- Add gsap_repeat_ceil_overshoot warning that catches
  repeat: Math.ceil(d/c)-1 patterns which overshoot the intended
  duration. Recommends Math.floor instead.
- Fix gsap_infinite_repeat fixHint to suggest Math.floor (not Math.ceil).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(player): wait for injected runtime before declaring ready

When the player auto-injects the runtime script (because the
composition has GSAP timelines but no runtime), it would immediately
declare ready on the next probe cycle — before the runtime script
finished loading from CDN. This caused play() to send a postMessage
that nobody received, making autoplay silently fail.

Now the probe waits for the runtime bridge (__hf or __player) to
appear before proceeding to the ready state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-04-12 10:41:13 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 794b02153d
commit 18de86e4bd
4 changed files with 69 additions and 5 deletions
@@ -210,6 +210,26 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// root_composition_missing_data_duration
({ rootTag }) => {
const findings: HyperframeLintFinding[] = [];
if (!rootTag) return findings;
const compId = readAttr(rootTag.raw, "data-composition-id");
if (!compId) return findings;
const hasDuration = readAttr(rootTag.raw, "data-duration") !== null;
if (!hasDuration) {
findings.push({
code: "root_composition_missing_data_duration",
severity: "warning",
message: `Root composition "${compId}" is missing data-duration. Without an explicit duration, the runtime may infer Infinity for compositions with repeating animations, causing playback issues.`,
fixHint:
'Add data-duration="X" to the root composition element, where X is the total duration in seconds.',
snippet: truncateSnippet(rootTag.raw),
});
}
return findings;
},
// standalone_composition_wrapped_in_template
({ rawSource, options }) => {
const findings: HyperframeLintFinding[] = [];
+33 -2
View File
@@ -500,9 +500,40 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
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.ceil(duration / cycleDuration) - 1`.",
"from the composition duration: `repeat: Math.floor(duration / cycleDuration) - 1`.",
fixHint:
"Replace `repeat: -1` with a finite count, e.g. `repeat: Math.ceil(totalDuration / singleCycleDuration) - 1`.",
"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.",
snippet: truncateSnippet(snippet),
});
}
}
return findings;
},
// gsap_repeat_ceil_overshoot
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const content = script.content;
// Match patterns like: repeat: Math.ceil(duration / X) - 1
// or repeat: Math.ceil(totalDuration / cycleDuration) - 1
const pattern = /repeat\s*:\s*Math\.ceil\s*\([^)]+\)\s*-\s*1/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const contextStart = Math.max(0, match.index - 40);
const contextEnd = Math.min(content.length, match.index + match[0].length + 40);
const snippet = content.slice(contextStart, contextEnd).trim();
findings.push({
code: "gsap_repeat_ceil_overshoot",
severity: "warning",
message:
"GSAP repeat calculation uses `Math.ceil` which can overshoot the composition duration. " +
"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`. " +
"Math.floor(10.5 / 2) - 1 = 4 repeats → 5 cycles × 2s = 10s ✓",
snippet: truncateSnippet(snippet),
});
}
+5 -1
View File
@@ -20,7 +20,11 @@ export function formatSpeed(speed: number): string {
}
export function formatTime(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
// Handle non-finite values gracefully
if (!Number.isFinite(seconds) || seconds < 0) {
return "0:00";
}
const s = Math.floor(seconds);
const m = Math.floor(s / 60);
const sec = s % 60;
return `${m}:${sec.toString().padStart(2, "0")}`;
+11 -2
View File
@@ -242,8 +242,12 @@ class HyperframesPlayer extends HTMLElement {
}
if (data.type === "timeline" && data.durationInFrames > 0) {
this._duration = data.durationInFrames / DEFAULT_FPS;
this.controlsApi?.updateTime(this._currentTime, this._duration);
// Ignore Infinity duration from runtime (caused by loop-inflated timelines without data-duration)
// The player already has duration from the initial probe, so keep that.
if (Number.isFinite(data.durationInFrames)) {
this._duration = data.durationInFrames / DEFAULT_FPS;
this.controlsApi?.updateTime(this._currentTime, this._duration);
}
}
if (data.type === "stage-size" && data.width > 0 && data.height > 0) {
@@ -280,6 +284,11 @@ class HyperframesPlayer extends HTMLElement {
return; // Wait for runtime to load and initialize
}
// Runtime was injected but hasn't loaded yet — keep waiting
if (this._runtimeInjected && !hasRuntime) {
return;
}
const getAdapter = () => {
if (win.__player && typeof win.__player.getDuration === "function") return win.__player;
if (win.__timelines) {