mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(core): preserve runtime transport contract
This commit is contained in:
@@ -1284,6 +1284,49 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(childTimeline.time()).toBeCloseTo(1, 1);
|
||||
});
|
||||
|
||||
it.each([24, 30, 60, 30_000 / 1_001])(
|
||||
"preserves public playback state across keepPlaying seeks at %s fps",
|
||||
(fps) => {
|
||||
const raf = createManualRaf();
|
||||
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
window.requestAnimationFrame =
|
||||
raf.requestAnimationFrame as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
|
||||
|
||||
document.body.innerHTML = `
|
||||
<div
|
||||
data-composition-id="main"
|
||||
data-root="true"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
></div>
|
||||
`;
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
window.__HF_EXPORT_RENDER_SEEK_CONFIG = {
|
||||
fps,
|
||||
fpsSource: "render-options",
|
||||
};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
const player = window.__player;
|
||||
player?.play();
|
||||
raf.step(500);
|
||||
player?.seek(2.07, { keepPlaying: true });
|
||||
|
||||
const quantized = Math.floor(2.07 * fps + 1e-9) / fps;
|
||||
expect(player?.isPlaying()).toBe(true);
|
||||
expect(player?.getTime()).toBeCloseTo(quantized, 6);
|
||||
|
||||
raf.step(500);
|
||||
expect(player?.isPlaying()).toBe(true);
|
||||
expect(player?.getTime()).toBeCloseTo(quantized + 0.5, 5);
|
||||
},
|
||||
);
|
||||
|
||||
it("sets __renderReady only after timeline is bound, not at __playerReady time", async () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
+123
-143
@@ -22,7 +22,7 @@ import { createWaapiAdapter } from "./adapters/waapi";
|
||||
import { refreshRuntimeMediaCache, syncRuntimeMedia } from "./media";
|
||||
import { probeAndCacheElementVolume, type VolumeKeyframe } from "./mediaVolumeEnvelope.js";
|
||||
import { createPickerModule } from "./picker";
|
||||
import { createRuntimePlayer } from "./player";
|
||||
import { createRuntimePlayer, type RuntimePlayerTransport } from "./player";
|
||||
import { createRuntimeState } from "./state";
|
||||
import { collectRuntimeTimelinePayload } from "./timeline";
|
||||
import { createRuntimeStartTimeResolver } from "./startResolver";
|
||||
@@ -117,6 +117,16 @@ export function initSandboxRuntimeModular(): void {
|
||||
swallow("runtime.init.site1", err);
|
||||
}
|
||||
}
|
||||
// Transport resources are initialized before any player or media closures.
|
||||
// This removes the old temporal-dead-zone fallback and lets the public player
|
||||
// be constructed once with its final clock-backed behavior.
|
||||
const clock = new TransportClock();
|
||||
state.transportClock = clock;
|
||||
const webAudio = new WebAudioTransport();
|
||||
let webAudioReady = false;
|
||||
void webAudio.init().then((ok) => {
|
||||
webAudioReady = ok;
|
||||
});
|
||||
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
|
||||
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
|
||||
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
|
||||
@@ -2101,6 +2111,117 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
};
|
||||
|
||||
const transport: RuntimePlayerTransport = {
|
||||
play: () => {
|
||||
const tl = state.capturedTimeline;
|
||||
if (clock.isPlaying()) return;
|
||||
const dur = getSafeTimelineDurationSeconds(tl, 0);
|
||||
if (dur > 0) {
|
||||
clock.setDuration(dur);
|
||||
if (clock.reachedEnd()) {
|
||||
clock.seek(0);
|
||||
state.currentTime = 0;
|
||||
seekTimelineAndAdapters(0);
|
||||
}
|
||||
} else {
|
||||
const rootEl = resolveRootCompositionElement();
|
||||
const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0);
|
||||
if (declaredDur > 0) clock.setDuration(declaredDur);
|
||||
}
|
||||
if (tl) tl.pause();
|
||||
if (!clock.play()) return;
|
||||
state.isPlaying = true;
|
||||
state.mediaForceSyncNextTick = true;
|
||||
hardSyncAllMedia(clock.now());
|
||||
// Schedule audio through WebAudio for sample-accurate timing.
|
||||
// Falls back to HTMLMediaElement playback if WebAudio isn't ready
|
||||
// or decoding fails (the syncRuntimeMedia path handles that).
|
||||
if (webAudioReady && !state.nativeMediaSyncDisabled && !state.webAudioMediaDisabled) {
|
||||
scheduleWebAudioForActiveClips();
|
||||
}
|
||||
runAdapters("play");
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
},
|
||||
pause: () => {
|
||||
if (!clock.isPlaying()) return;
|
||||
webAudio.stopAll();
|
||||
clock.detachAudioSource();
|
||||
clock.pause();
|
||||
state.isPlaying = false;
|
||||
state.currentTime = clock.now();
|
||||
state.mediaForceSyncNextTick = true;
|
||||
hardSyncAllMedia(state.currentTime);
|
||||
const tl = state.capturedTimeline;
|
||||
if (tl) tl.pause();
|
||||
runAdapters("pause");
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
},
|
||||
seek: (timeSeconds, options) => {
|
||||
const quantized = quantizeTimeToFrame(
|
||||
Math.max(0, Number(timeSeconds) || 0),
|
||||
state.canonicalFps,
|
||||
);
|
||||
webAudio.stopAll();
|
||||
clock.detachAudioSource();
|
||||
const wasPlaying = clock.isPlaying();
|
||||
if (wasPlaying) clock.pause();
|
||||
clock.seek(quantized);
|
||||
state.currentTime = clock.now();
|
||||
state.isPlaying = false;
|
||||
state.mediaForceSyncNextTick = true;
|
||||
const tl = state.capturedTimeline;
|
||||
if (tl) tl.pause();
|
||||
seekTimelineAndAdapters(state.currentTime);
|
||||
runAdapters("pause");
|
||||
if (options?.keepPlaying && wasPlaying) {
|
||||
transport.play();
|
||||
return;
|
||||
}
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
},
|
||||
renderSeek: (timeSeconds, options) => {
|
||||
const quantized = quantizeTimeToFrame(
|
||||
Math.max(0, Number(timeSeconds) || 0),
|
||||
state.canonicalFps,
|
||||
);
|
||||
webAudio.stopAll();
|
||||
clock.detachAudioSource();
|
||||
if (clock.isPlaying()) clock.pause();
|
||||
clock.seek(quantized);
|
||||
state.currentTime = clock.now();
|
||||
state.isPlaying = false;
|
||||
state.mediaForceSyncNextTick = true;
|
||||
seekTimelineAndAdapters(state.currentTime, {
|
||||
activateChildren: true,
|
||||
suppressEvents: options?.suppressEvents,
|
||||
});
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
},
|
||||
getTime: () => clock.now(),
|
||||
getDuration: () => {
|
||||
const dur = clock.getDuration();
|
||||
return Number.isFinite(dur) ? dur : 0;
|
||||
},
|
||||
isPlaying: () => clock.isPlaying(),
|
||||
setPlaybackRate: (rate) => {
|
||||
applyPlaybackRate(rate);
|
||||
clock.setRate(state.playbackRate);
|
||||
applyWebAudioRate();
|
||||
},
|
||||
getPlaybackRate: () => state.playbackRate,
|
||||
};
|
||||
|
||||
const initialDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
if (initialDuration > 0) clock.setDuration(initialDuration);
|
||||
|
||||
const player = createRuntimePlayer({
|
||||
getTimeline: () => state.capturedTimeline,
|
||||
setTimeline: (timeline) => {
|
||||
@@ -2144,6 +2265,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
},
|
||||
onShowNativeVideos: () => {},
|
||||
getSafeDuration: () => getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
|
||||
transport,
|
||||
});
|
||||
|
||||
window.__player = createPlayerApiCompat(player);
|
||||
@@ -2298,19 +2420,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
installRuntimeErrorDiagnostics();
|
||||
bindMediaMetadataListeners();
|
||||
runAdapters("discover");
|
||||
// ── Single-clock transport ──
|
||||
//
|
||||
// TransportClock is the sole time authority. GSAP is always paused —
|
||||
// seeked to clock.now() on each rAF tick. This eliminates the
|
||||
// two-clock drift problem from issue #668: one clock, zero drift.
|
||||
const clock = new TransportClock();
|
||||
state.transportClock = clock;
|
||||
const webAudio = new WebAudioTransport();
|
||||
let webAudioReady = false;
|
||||
void webAudio.init().then((ok) => {
|
||||
webAudioReady = ok;
|
||||
});
|
||||
|
||||
const publishRenderReadyAfterTimelineBinding = () => {
|
||||
const prevTimeline = state.capturedTimeline;
|
||||
const rebound = bindRootTimelineIfAvailable();
|
||||
@@ -2862,109 +2971,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
};
|
||||
|
||||
player.play = () => {
|
||||
const tl = state.capturedTimeline;
|
||||
if (clock.isPlaying()) return;
|
||||
const dur = getSafeTimelineDurationSeconds(tl, 0);
|
||||
if (dur > 0) {
|
||||
clock.setDuration(dur);
|
||||
if (clock.reachedEnd()) {
|
||||
clock.seek(0);
|
||||
state.currentTime = 0;
|
||||
seekTimelineAndAdapters(0);
|
||||
}
|
||||
} else {
|
||||
const rootEl = resolveRootCompositionElement();
|
||||
const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0);
|
||||
if (declaredDur > 0) clock.setDuration(declaredDur);
|
||||
}
|
||||
if (tl) tl.pause();
|
||||
if (!clock.play()) return;
|
||||
state.isPlaying = true;
|
||||
state.mediaForceSyncNextTick = true;
|
||||
hardSyncAllMedia(clock.now());
|
||||
// Schedule audio through WebAudio for sample-accurate timing.
|
||||
// Falls back to HTMLMediaElement playback if WebAudio isn't ready
|
||||
// or decoding fails (the syncRuntimeMedia path handles that).
|
||||
if (webAudioReady && !state.nativeMediaSyncDisabled && !state.webAudioMediaDisabled) {
|
||||
scheduleWebAudioForActiveClips();
|
||||
}
|
||||
runAdapters("play");
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
player.pause = () => {
|
||||
if (!clock.isPlaying()) return;
|
||||
webAudio.stopAll();
|
||||
clock.detachAudioSource();
|
||||
clock.pause();
|
||||
state.isPlaying = false;
|
||||
state.currentTime = clock.now();
|
||||
state.mediaForceSyncNextTick = true;
|
||||
hardSyncAllMedia(state.currentTime);
|
||||
const tl = state.capturedTimeline;
|
||||
if (tl) tl.pause();
|
||||
runAdapters("pause");
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
player.seek = (timeSeconds: number) => {
|
||||
const quantized = quantizeTimeToFrame(
|
||||
Math.max(0, Number(timeSeconds) || 0),
|
||||
state.canonicalFps,
|
||||
);
|
||||
webAudio.stopAll();
|
||||
clock.detachAudioSource();
|
||||
const wasPlaying = clock.isPlaying();
|
||||
if (wasPlaying) clock.pause();
|
||||
clock.seek(quantized);
|
||||
state.currentTime = clock.now();
|
||||
state.isPlaying = false;
|
||||
state.mediaForceSyncNextTick = true;
|
||||
const tl = state.capturedTimeline;
|
||||
if (tl) tl.pause();
|
||||
seekTimelineAndAdapters(state.currentTime);
|
||||
runAdapters("pause");
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
player.renderSeek = (timeSeconds: number, options?: RuntimeSeekOptions) => {
|
||||
const quantized = quantizeTimeToFrame(
|
||||
Math.max(0, Number(timeSeconds) || 0),
|
||||
state.canonicalFps,
|
||||
);
|
||||
if (clock.isPlaying()) clock.pause();
|
||||
clock.seek(quantized);
|
||||
state.currentTime = clock.now();
|
||||
state.isPlaying = false;
|
||||
state.mediaForceSyncNextTick = true;
|
||||
seekTimelineAndAdapters(state.currentTime, {
|
||||
activateChildren: true,
|
||||
suppressEvents: options?.suppressEvents,
|
||||
});
|
||||
syncMediaForCurrentState();
|
||||
colorGrading.redraw();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
player.getTime = () => clock.now();
|
||||
player.getDuration = () => {
|
||||
const dur = clock.getDuration();
|
||||
return Number.isFinite(dur) ? dur : 0;
|
||||
};
|
||||
player.isPlaying = () => clock.isPlaying();
|
||||
player.setPlaybackRate = (rate: number) => {
|
||||
applyPlaybackRate(rate);
|
||||
clock.setRate(state.playbackRate);
|
||||
applyWebAudioRate();
|
||||
};
|
||||
|
||||
// Sync clock duration from any captured timeline
|
||||
if (state.capturedTimeline) {
|
||||
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
@@ -2972,32 +2978,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
state.capturedTimeline.pause();
|
||||
}
|
||||
|
||||
// Re-delegate __player methods through the live `player` object so
|
||||
// transport clock overrides are visible to iframe consumers reading
|
||||
// window.__player. Uses property delegation so future methods added
|
||||
// to createPlayerApiCompat are forwarded automatically.
|
||||
const playerApi = window.__player;
|
||||
if (playerApi) {
|
||||
const delegated = [
|
||||
"play",
|
||||
"pause",
|
||||
"seek",
|
||||
"renderSeek",
|
||||
"getTime",
|
||||
"getDuration",
|
||||
"isPlaying",
|
||||
] as const;
|
||||
for (const key of delegated) {
|
||||
Object.defineProperty(playerApi, key, {
|
||||
get: () => player[key],
|
||||
set: (v: unknown) => {
|
||||
(player as Record<string, unknown>)[key] = v;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
installPositionEditsSeekReapply(window as Window & typeof globalThis);
|
||||
|
||||
// Start the rAF tick loop
|
||||
|
||||
@@ -134,6 +134,34 @@ function createNestedTimelineHarness() {
|
||||
}
|
||||
|
||||
describe("createRuntimePlayer", () => {
|
||||
describe("dedicated transport", () => {
|
||||
it("keeps factory-owned methods stable and forwards the complete seek contract", () => {
|
||||
const timeline = createMockTimeline();
|
||||
const deps = createMockDeps(timeline);
|
||||
const transport = {
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
seek: vi.fn(),
|
||||
renderSeek: vi.fn(),
|
||||
getTime: vi.fn(() => 4),
|
||||
getDuration: vi.fn(() => 10),
|
||||
isPlaying: vi.fn(() => true),
|
||||
setPlaybackRate: vi.fn(),
|
||||
getPlaybackRate: vi.fn(() => 1.5),
|
||||
};
|
||||
const player = createRuntimePlayer({ ...deps, transport });
|
||||
const seek = player.seek;
|
||||
|
||||
player.seek(2.5, { keepPlaying: true });
|
||||
|
||||
expect(player.seek).toBe(seek);
|
||||
expect(transport.seek).toHaveBeenCalledWith(2.5, { keepPlaying: true });
|
||||
expect(player.getTime()).toBe(4);
|
||||
expect(player.getPlaybackRate()).toBe(1.5);
|
||||
expect(deps.onDeterministicSeek).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("play", () => {
|
||||
it("does nothing without a timeline", () => {
|
||||
const deps = createMockDeps(null);
|
||||
|
||||
@@ -31,6 +31,8 @@ function safeVoid(obj: unknown, method: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export type RuntimePlayerTransport = Omit<RuntimePlayer, "_timeline">;
|
||||
|
||||
type PlayerDeps = {
|
||||
getTimeline: () => RuntimeTimelineLike | null;
|
||||
setTimeline: (timeline: RuntimeTimelineLike | null) => void;
|
||||
@@ -56,6 +58,12 @@ type PlayerDeps = {
|
||||
* animations would continue to advance visually past the paused time.
|
||||
*/
|
||||
getTimelineRegistry?: () => Record<string, RuntimeTimelineLike | undefined>;
|
||||
/**
|
||||
* Optional transport implementation for runtimes with a dedicated clock.
|
||||
* The public player methods remain factory-owned and stable for the lifetime
|
||||
* of the runtime; callers must never replace them after construction.
|
||||
*/
|
||||
transport?: RuntimePlayerTransport;
|
||||
};
|
||||
|
||||
function forEachSiblingTimeline(
|
||||
@@ -128,6 +136,22 @@ function activateSiblingTimelines(
|
||||
}
|
||||
|
||||
export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||
const transport = deps.transport;
|
||||
if (transport) {
|
||||
return {
|
||||
_timeline: null,
|
||||
play: () => transport.play(),
|
||||
pause: () => transport.pause(),
|
||||
seek: (timeSeconds, options) => transport.seek(timeSeconds, options),
|
||||
renderSeek: (timeSeconds, options) => transport.renderSeek(timeSeconds, options),
|
||||
getTime: () => transport.getTime(),
|
||||
getDuration: () => transport.getDuration(),
|
||||
isPlaying: () => transport.isPlaying(),
|
||||
setPlaybackRate: (rate) => transport.setPlaybackRate(rate),
|
||||
getPlaybackRate: () => transport.getPlaybackRate(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
_timeline: null,
|
||||
play: () => {
|
||||
|
||||
@@ -67,6 +67,87 @@ describe("core runtime browser contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([24, 30, 60, 30_000 / 1_001])(
|
||||
"keeps the real public player running across a seek at %s fps",
|
||||
async (fps) => {
|
||||
const fpsPage = await browser.newPage();
|
||||
try {
|
||||
await fpsPage.setContent(`<!doctype html>
|
||||
<style>
|
||||
@keyframes slide {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(100px); }
|
||||
}
|
||||
#box { animation: slide 4s linear both; }
|
||||
</style>
|
||||
<div
|
||||
data-composition-id="root"
|
||||
data-start="0"
|
||||
data-duration="4"
|
||||
data-width="320"
|
||||
data-height="180"
|
||||
>
|
||||
<div id="box"></div>
|
||||
</div>`);
|
||||
await fpsPage.evaluate((runtimeFps) => {
|
||||
(
|
||||
window as unknown as {
|
||||
__HF_EXPORT_RENDER_SEEK_CONFIG?: {
|
||||
fps: number;
|
||||
fpsSource: "render-options";
|
||||
};
|
||||
}
|
||||
).__HF_EXPORT_RENDER_SEEK_CONFIG = {
|
||||
fps: runtimeFps,
|
||||
fpsSource: "render-options",
|
||||
};
|
||||
}, fps);
|
||||
await fpsPage.addScriptTag({ content: readFileSync(RUNTIME_PATH, "utf8") });
|
||||
await fpsPage.waitForFunction(
|
||||
() =>
|
||||
(window as unknown as { __playerReady?: boolean }).__playerReady === true &&
|
||||
(window as unknown as { __renderReady?: boolean }).__renderReady === true,
|
||||
);
|
||||
|
||||
const result = await fpsPage.evaluate(async () => {
|
||||
const player = (
|
||||
window as unknown as {
|
||||
__player?: {
|
||||
play: () => void;
|
||||
seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => void;
|
||||
getTime: () => number;
|
||||
isPlaying: () => boolean;
|
||||
};
|
||||
}
|
||||
).__player;
|
||||
if (!player) throw new Error("runtime player was not installed");
|
||||
|
||||
player.play();
|
||||
player.seek(1.123, { keepPlaying: true });
|
||||
const timeAfterSeek = player.getTime();
|
||||
const playingAfterSeek = player.isPlaying();
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 80));
|
||||
|
||||
return {
|
||||
timeAfterSeek,
|
||||
playingAfterSeek,
|
||||
timeAfterDelay: player.getTime(),
|
||||
playingAfterDelay: player.isPlaying(),
|
||||
};
|
||||
});
|
||||
|
||||
const expectedSeek = Math.floor(1.123 * fps + 1e-9) / fps;
|
||||
expect(result.timeAfterSeek).toBeCloseTo(expectedSeek, 1);
|
||||
expect(result.playingAfterSeek).toBe(true);
|
||||
expect(result.playingAfterDelay).toBe(true);
|
||||
expect(result.timeAfterDelay).toBeGreaterThan(result.timeAfterSeek + 0.04);
|
||||
} finally {
|
||||
await fpsPage.close();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it("removes the control bridge during teardown", async () => {
|
||||
const result = await page.evaluate(async () => {
|
||||
const runtimeWindow = window as unknown as {
|
||||
|
||||
Reference in New Issue
Block a user