Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
266 changes: 123 additions & 143 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -2144,6 +2265,7 @@ export function initSandboxRuntimeModular(): void {
},
onShowNativeVideos: () => {},
getSafeDuration: () => getSafeTimelineDurationSeconds(state.capturedTimeline, 0),
transport,
});

window.__player = createPlayerApiCompat(player);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2862,142 +2971,13 @@ 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);
if (dur > 0) clock.setDuration(dur);
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
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/runtime/player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading