mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
## 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
189 lines
6.6 KiB
TypeScript
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(),
|
|
};
|
|
}
|