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
+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