Files
hyperframes/packages/core/src/runtime/player.ts
T
Miguel Ángel d740f5ce42 fix: nested GSAP sub-composition lint and render handling (#405)
## Summary

- allow nested sub-composition files to inherit GSAP from their host without tripping `missing_gsap_script`
- keep nested render seeks stable for sub-compositions without regressing producer baselines
- stop producer render-hint detection from treating the compiler's own nested mount retry wrapper as user-authored `requestAnimationFrame()` usage

## Root Cause

- the core linter treated template-based nested compositions like standalone root compositions, so it incorrectly required a local GSAP loader even when the host composition already provided GSAP
- producer `detectRenderModeHints()` runs before CDN scripts are inlined, so nested GSAP exports were never failing because of the GSAP payload itself
- the nested-only false positive came from the compiler-generated mount bootstrap that waits for the inlined sub-composition root with `requestAnimationFrame()` before running the hoisted inline script
- preview and export seek paths also needed to stay split so the nested timeline re-arm behavior that stabilizes scrubbing does not collapse render baselines

## What Changed

- lint: keep the nested GSAP false-positive fix and regression coverage for template sub-compositions
- runtime: keep the render-seek behavior that preserves nested child offsets during export without changing preview scrubbing behavior
- producer: mark compiler-owned mount bootstrap blocks and strip only those blocks before scanning inline scripts for raw `requestAnimationFrame()`
- producer tests now cover both cases: compiler-generated wrappers are ignored, but real user-authored nested `requestAnimationFrame()` still opts into screenshot mode

## Validation

- `bun test packages/core/src/lint/rules/gsap.test.ts`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- `bunx oxfmt packages/producer/src/services/htmlCompiler.ts packages/producer/src/services/htmlCompiler.test.ts`
- `bunx oxlint packages/producer/src/services/htmlCompiler.ts packages/producer/src/services/htmlCompiler.test.ts`
- `bun run --filter @hyperframes/producer test --sequential chat style-11-prod`
  - `style-11-prod` passed locally
  - `chat` still shows local-only visual drift on this macOS/ARM workstation, but the render metadata now reports `renderModeHints.recommendScreenshot=false`, which is the concrete acceptance condition for `#402`
- Docker CI-image repro is blocked locally by OrbStack x86/arm64 loader mismatch, so final regression confirmation is deferred to GitHub Actions

Closes #392
Closes #402
2026-04-22 16:10:38 +02:00

189 lines
6.6 KiB
TypeScript

import type { RuntimePlayer, RuntimeTimelineLike } from "./types";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
type PlayerDeps = {
getTimeline: () => RuntimeTimelineLike | null;
setTimeline: (timeline: RuntimeTimelineLike | null) => void;
getIsPlaying: () => boolean;
setIsPlaying: (playing: boolean) => void;
getPlaybackRate: () => number;
setPlaybackRate: (rate: number) => void;
getCanonicalFps: () => number;
onSyncMedia: (timeSeconds: number, playing: boolean) => void;
onStatePost: (force: boolean) => void;
onDeterministicSeek: (timeSeconds: number) => void;
onDeterministicPause: () => void;
onDeterministicPlay: () => void;
onRenderFrameSeek: (timeSeconds: number) => void;
onShowNativeVideos: () => void;
getSafeDuration?: () => number;
/**
* Optional registry of sibling timelines (typically `window.__timelines`).
* Provided so that play/pause propagate to sub-scene timelines registered
* alongside the master — e.g. a nested-composition master with per-scene
* timelines like `scene1-logo-intro`, `scene2-4-canvas`. Without this,
* pausing the master would leave scene timelines free-running and
* animations would continue to advance visually past the paused time.
*/
getTimelineRegistry?: () => Record<string, RuntimeTimelineLike | undefined>;
};
function forEachSiblingTimeline(
registry: Record<string, RuntimeTimelineLike | undefined> | undefined | null,
master: RuntimeTimelineLike,
fn: (tl: RuntimeTimelineLike) => void,
): void {
if (!registry) return;
for (const tl of Object.values(registry)) {
if (!tl || tl === master) continue;
try {
fn(tl);
} catch {
// ignore sibling failures — one broken timeline shouldn't poison play/pause
}
}
}
function seekTimelineDeterministically(
timeline: RuntimeTimelineLike,
timeSeconds: number,
canonicalFps: number,
): number {
const quantized = quantizeTimeToFrame(timeSeconds, canonicalFps);
timeline.pause();
if (typeof timeline.totalTime === "function") {
timeline.totalTime(quantized, false);
} else {
timeline.seek(quantized, false);
}
return quantized;
}
function seekMasterAndSiblingTimelinesDeterministically(
registry: Record<string, RuntimeTimelineLike | undefined> | undefined | null,
master: RuntimeTimelineLike,
timeSeconds: number,
canonicalFps: number,
): number {
const rearmedSiblings: RuntimeTimelineLike[] = [];
forEachSiblingTimeline(registry, master, (tl) => {
tl.play();
rearmedSiblings.push(tl);
});
try {
return seekTimelineDeterministically(master, timeSeconds, canonicalFps);
} finally {
for (const tl of rearmedSiblings) {
try {
tl.pause();
} catch {
// ignore sibling failures — one broken timeline shouldn't poison seek
}
}
}
}
function activateSiblingTimelines(
registry: Record<string, RuntimeTimelineLike | undefined> | undefined | null,
master: RuntimeTimelineLike,
): void {
forEachSiblingTimeline(registry, master, (tl) => {
tl.play();
});
}
export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
return {
_timeline: null,
play: () => {
const timeline = deps.getTimeline();
if (!timeline || deps.getIsPlaying()) return;
const safeDuration = Math.max(
0,
Number(deps.getSafeDuration?.() ?? timeline.duration() ?? 0) || 0,
);
if (safeDuration > 0) {
const currentTime = Math.max(0, Number(timeline.time()) || 0);
if (currentTime >= safeDuration) {
timeline.pause();
timeline.seek(0, false);
deps.onDeterministicSeek(0);
deps.setIsPlaying(false);
deps.onSyncMedia(0, false);
deps.onRenderFrameSeek(0);
}
}
if (typeof timeline.timeScale === "function") {
timeline.timeScale(deps.getPlaybackRate());
}
timeline.play();
forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => {
if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate());
tl.play();
});
deps.onDeterministicPlay();
deps.setIsPlaying(true);
deps.onShowNativeVideos();
deps.onStatePost(true);
},
pause: () => {
const timeline = deps.getTimeline();
if (!timeline) return;
timeline.pause();
forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => {
tl.pause();
});
const time = Math.max(0, Number(timeline.time()) || 0);
deps.onDeterministicSeek(time);
deps.onDeterministicPause();
deps.setIsPlaying(false);
deps.onSyncMedia(time, false);
deps.onRenderFrameSeek(time);
deps.onStatePost(true);
},
seek: (timeSeconds: number) => {
const timeline = deps.getTimeline();
if (!timeline) return;
const safeTime = Math.max(0, Number(timeSeconds) || 0);
const quantized = seekMasterAndSiblingTimelinesDeterministically(
deps.getTimelineRegistry?.(),
timeline,
safeTime,
deps.getCanonicalFps(),
);
deps.onDeterministicSeek(quantized);
deps.setIsPlaying(false);
deps.onSyncMedia(quantized, false);
deps.onRenderFrameSeek(quantized);
deps.onStatePost(true);
},
renderSeek: (timeSeconds: number) => {
const timeline = deps.getTimeline();
const canonicalFps = deps.getCanonicalFps();
// When a composition has no GSAP timeline (pure CSS / WAAPI / Lottie /
// Three.js adapters driving the animation), still seek the adapters so
// their animations advance. Without this, non-GSAP compositions freeze
// on their initial frame.
const quantized = timeline
? (() => {
// Export seeks run frame-by-frame through the resolved root timeline.
// If nested siblings stay paused, GSAP collapses the root back to the
// authored master duration and later frames clamp incorrectly.
activateSiblingTimelines(deps.getTimelineRegistry?.(), timeline);
return seekTimelineDeterministically(timeline, timeSeconds, canonicalFps);
})()
: quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps);
deps.onDeterministicSeek(quantized);
deps.setIsPlaying(false);
deps.onSyncMedia(quantized, false);
deps.onRenderFrameSeek(quantized);
deps.onStatePost(true);
},
getTime: () => Number(deps.getTimeline()?.time() ?? 0),
getDuration: () => Number(deps.getTimeline()?.duration() ?? 0),
isPlaying: () => deps.getIsPlaying(),
setPlaybackRate: (rate: number) => deps.setPlaybackRate(rate),
getPlaybackRate: () => deps.getPlaybackRate(),
};
}