fix(core): preserve runtime transport contract

This commit is contained in:
James
2026-07-13 14:08:52 -04:00
parent a364f69568
commit cc3ca2f9f7
5 changed files with 299 additions and 143 deletions
+43
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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);
+24
View File
@@ -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 {