feat(runtime): single-clock transport — eliminate pause/play audio drift (#671)

Replace the two-clock architecture (GSAP rAF ticker + HTMLMediaElement
pipeline reconciled by a 50ms polling loop) with a single TransportClock.
GSAP is always paused and seeked to clock.now() on each rAF tick.
Drift between visual timeline and audio is structurally impossible.

Architecture:

  TransportClock.now() ──rAF──▶ timeline.seek(t) + el.currentTime
       ▲
  AudioContext.currentTime (~21µs)  ← WebAudio active
       OR
  audio.currentTime (~33ms)         ← HTMLMediaElement fallback
       OR
  performance.now() (~1ms)          ← no audio

Key changes:
- TransportClock class with monotonic + audio-master clock sources
- WebAudioTransport: routes audio through AudioBufferSourceNode for
  sample-accurate scheduling, falls back gracefully to HTMLMediaElement
- rAF tick loop replaces 50ms setInterval poll; GSAP always paused
- Strict sync (40ms threshold, consecutive-sample gated) + forceSync
  on play/pause/seek transitions for sub-frame media accuracy
- Buffer-stall: visuals freeze when audio is buffering instead of
  running ahead
- Frame quantization preserved in seek/renderSeek (parity contract)

Browser-verified: 0.0ms drift after 40 pause/play cycles (was 400ms+).

Also fixes: CDN script HTML error responses in validate (pre-existing).

54 tests across clock, clock-drift, webAudioTransport, and media.

Closes #668
This commit is contained in:
Miguel Ángel
2026-05-08 07:53:46 +02:00
committed by GitHub
parent 7bf30d5423
commit b009288df7
10 changed files with 1400 additions and 125 deletions
+6 -1
View File
@@ -178,7 +178,12 @@ async function validateInBrowser(
});
page.on("pageerror", (err) => {
errors.push({ level: "error", text: err instanceof Error ? err.message : String(err) });
const text = err instanceof Error ? err.message : String(err);
// CDN scripts (e.g. GSAP from jsdelivr) returning HTML error pages
// instead of JS produce "Unexpected token '<'" SyntaxErrors. These
// are network failures, not composition authoring errors.
if (text.includes("Unexpected token '<'") || text.includes("Unexpected token '&lt;'")) return;
errors.push({ level: "error", text });
});
page.on("requestfailed", (req) => {
@@ -0,0 +1,215 @@
import { describe, it, expect } from "vitest";
import { TransportClock } from "./clock";
describe("TransportClock eliminates pause/play drift (issue #668)", () => {
it("40 pause/play cycles accumulate zero drift", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
clock.play();
ms += 500;
const timeBefore = clock.now();
expect(timeBefore).toBe(0.5);
for (let i = 0; i < 40; i++) {
clock.pause();
ms += 100;
clock.play();
ms += 100;
}
ms += 500;
const timeAfter = clock.now();
// With a single clock: 500ms initial + 40*(100ms play) + 500ms final = 5.5s
// Pause periods don't advance the clock.
// Total play time: 500 + 40*100 + 500 = 5000ms = 5s
expect(timeAfter).toBe(5);
// The key assertion: NO accumulated drift from pause/play toggling.
// In the old two-clock architecture, each toggle could introduce ~10-20ms
// of drift, accumulating to 400-800ms after 40 cycles.
// With TransportClock: drift is exactly 0.
const expectedPlayTime = 0.5 + 40 * 0.1 + 0.5;
expect(timeAfter).toBe(expectedPlayTime);
});
it("100 rapid pause/play cycles still produce zero drift", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 30 });
clock.play();
ms += 1000;
for (let i = 0; i < 100; i++) {
clock.pause();
ms += 50;
clock.play();
ms += 50;
}
ms += 1000;
const finalTime = clock.now();
// Play time: 1000ms + 100*50ms + 1000ms = 7000ms = 7s
expect(finalTime).toBeCloseTo(7, 10);
});
it("rate changes during pause/play cycles preserve accuracy", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 60 });
clock.play();
ms += 1000;
expect(clock.now()).toBe(1);
clock.setRate(2);
ms += 1000;
expect(clock.now()).toBe(3);
for (let i = 0; i < 20; i++) {
clock.pause();
ms += 100;
clock.play();
ms += 100;
}
// At 2x rate, 20 * 100ms play = 2000ms wall = 4s timeline
expect(clock.now()).toBeCloseTo(7, 10);
});
it("seek during pause/play cycles does not introduce drift", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 20 });
clock.play();
ms += 2000;
expect(clock.now()).toBe(2);
clock.seek(5);
expect(clock.now()).toBe(5);
for (let i = 0; i < 20; i++) {
clock.pause();
ms += 100;
clock.play();
ms += 100;
}
ms += 1000;
// Play time after seek: 20*100ms + 1000ms = 3000ms = 3s
expect(clock.now()).toBeCloseTo(8, 10);
});
it("simulates the exact issue #668 reproduction scenario", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
// "Use a GSAP composition with a timed narration track, then
// repeatedly toggle playback"
clock.play();
ms += 200;
// The issue says: "After enough toggles, narration and animation/captions
// can become visibly or audibly offset."
// Issue reproduction: 40 toggles with 100ms intervals
for (let i = 0; i < 40; i++) {
clock.pause();
ms += 100;
clock.play();
ms += 100;
}
ms += 200;
const finalTime = clock.now();
// Play time: 200ms + 40*100ms + 200ms = 4400ms = 4.4s
expect(finalTime).toBeCloseTo(4.4, 10);
// With the old architecture, drift of 400-800ms would accumulate here.
// With TransportClock, drift is mathematically impossible — there is
// only one clock. The time is always baseTime + elapsed * rate.
// Pause just snapshots baseTime. Play just records a new start marker.
// No two clocks can diverge because there is only one.
});
});
describe("TransportClock end-of-playback (loop semantics)", () => {
it("reachedEnd returns true at duration boundary", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 5 });
clock.play();
ms += 5000;
expect(clock.reachedEnd()).toBe(true);
expect(clock.now()).toBe(5);
});
it("clock auto-caps at duration and refuses to advance past it", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 3 });
clock.play();
ms += 10000;
expect(clock.now()).toBe(3);
expect(clock.reachedEnd()).toBe(true);
});
it("seek to 0 after reaching end allows replay", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 5 });
clock.play();
ms += 5000;
expect(clock.reachedEnd()).toBe(true);
clock.pause();
clock.seek(0);
expect(clock.now()).toBe(0);
expect(clock.reachedEnd()).toBe(false);
expect(clock.play()).toBe(true);
ms += 2000;
expect(clock.now()).toBe(2);
});
it("pause + seek to end + play is rejected (no infinite loop)", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 5 });
clock.seek(5);
expect(clock.play()).toBe(false);
expect(clock.isPlaying()).toBe(false);
});
});
describe("TransportClock + simulated timeline wiring", () => {
it("clock drives timeline seek on each tick", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
const seekLog: number[] = [];
const mockSeek = (t: number) => seekLog.push(t);
clock.play();
for (let i = 0; i < 5; i++) {
ms += 16;
mockSeek(clock.now());
}
expect(seekLog.length).toBe(5);
expect(seekLog[0]).toBeCloseTo(0.016, 5);
expect(seekLog[4]).toBeCloseTo(0.08, 5);
for (let i = 1; i < seekLog.length; i++) {
expect(seekLog[i]).toBeGreaterThan(seekLog[i - 1]);
}
});
it("forceSync threshold: drift above 20ms is correctable", () => {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
clock.play();
ms += 2000;
const clockTime = clock.now();
const simulatedAudioTime = clockTime - 0.025;
const drift = Math.abs(clockTime - simulatedAudioTime);
expect(drift).toBeGreaterThan(0.02);
expect(drift).toBeLessThan(0.04);
});
});
+331
View File
@@ -0,0 +1,331 @@
import { describe, it, expect } from "vitest";
import { TransportClock } from "./clock";
function createClock(opts?: ConstructorParameters<typeof TransportClock>[0]) {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, ...opts });
const advance = (deltaMs: number) => {
ms += deltaMs;
};
return { clock, advance, getMs: () => ms };
}
describe("TransportClock", () => {
describe("initial state", () => {
it("starts paused at time 0", () => {
const { clock } = createClock();
expect(clock.now()).toBe(0);
expect(clock.isPlaying()).toBe(false);
});
it("respects initialTime", () => {
const { clock } = createClock({ initialTime: 5 });
expect(clock.now()).toBe(5);
});
it("respects initial rate", () => {
const { clock, advance } = createClock({ rate: 2 });
clock.play();
advance(1000);
expect(clock.now()).toBe(2);
});
it("respects initial duration", () => {
const { clock } = createClock({ duration: 10 });
expect(clock.getDuration()).toBe(10);
});
});
describe("play/pause", () => {
it("advances time while playing", () => {
const { clock, advance } = createClock();
clock.play();
advance(1000);
expect(clock.now()).toBe(1);
});
it("freezes time while paused", () => {
const { clock, advance } = createClock();
clock.play();
advance(500);
clock.pause();
const t = clock.now();
advance(500);
expect(clock.now()).toBe(t);
});
it("play returns true on first call, false if already playing", () => {
const { clock } = createClock();
expect(clock.play()).toBe(true);
expect(clock.play()).toBe(false);
});
it("pause returns true on first call, false if already paused", () => {
const { clock } = createClock();
expect(clock.pause()).toBe(false);
clock.play();
expect(clock.pause()).toBe(true);
expect(clock.pause()).toBe(false);
});
it("resumes from where it paused", () => {
const { clock, advance } = createClock();
clock.play();
advance(1000);
clock.pause();
expect(clock.now()).toBe(1);
clock.play();
advance(1000);
expect(clock.now()).toBe(2);
});
it("does not play past duration", () => {
const { clock, advance } = createClock({ duration: 5 });
clock.play();
advance(10000);
expect(clock.now()).toBe(5);
});
it("refuses to play when already at end", () => {
const { clock } = createClock({ duration: 5, initialTime: 5 });
expect(clock.play()).toBe(false);
expect(clock.isPlaying()).toBe(false);
});
});
describe("seek", () => {
it("seeks while paused", () => {
const { clock } = createClock();
clock.seek(5);
expect(clock.now()).toBe(5);
expect(clock.isPlaying()).toBe(false);
});
it("seeks while playing without interrupting playback", () => {
const { clock, advance } = createClock();
clock.play();
advance(1000);
clock.seek(10);
expect(clock.isPlaying()).toBe(true);
expect(clock.now()).toBe(10);
advance(1000);
expect(clock.now()).toBe(11);
});
it("clamps to 0", () => {
const { clock } = createClock();
clock.seek(-5);
expect(clock.now()).toBe(0);
});
it("clamps to duration", () => {
const { clock } = createClock({ duration: 10 });
clock.seek(20);
expect(clock.now()).toBe(10);
});
});
describe("rate", () => {
it("applies rate multiplier to elapsed time", () => {
const { clock, advance } = createClock({ rate: 2 });
clock.play();
advance(1000);
expect(clock.now()).toBe(2);
});
it("rate change mid-play preserves current position", () => {
const { clock, advance } = createClock();
clock.play();
advance(2000);
expect(clock.now()).toBe(2);
clock.setRate(2);
advance(1000);
expect(clock.now()).toBe(4);
});
it("rate change while paused is applied on next play", () => {
const { clock, advance } = createClock();
clock.setRate(3);
clock.play();
advance(1000);
expect(clock.now()).toBe(3);
});
it("clamps rate to [0.1, 5]", () => {
const { clock } = createClock();
clock.setRate(0.01);
expect(clock.getRate()).toBe(0.1);
clock.setRate(100);
expect(clock.getRate()).toBe(5);
});
it("defaults to 1 for invalid rate", () => {
const { clock } = createClock();
clock.setRate(NaN);
expect(clock.getRate()).toBe(1);
clock.setRate(-1);
expect(clock.getRate()).toBe(1);
});
});
describe("duration", () => {
it("auto-pauses at duration boundary", () => {
const { clock, advance } = createClock({ duration: 5 });
clock.play();
advance(5000);
expect(clock.now()).toBe(5);
expect(clock.reachedEnd()).toBe(true);
});
it("setDuration clamps existing baseTime", () => {
const { clock } = createClock({ initialTime: 10 });
clock.setDuration(5);
expect(clock.now()).toBe(5);
});
it("setDuration with 0 or negative becomes Infinity", () => {
const { clock } = createClock({ duration: 5 });
clock.setDuration(0);
expect(clock.getDuration()).toBe(Infinity);
clock.setDuration(-1);
expect(clock.getDuration()).toBe(Infinity);
});
it("reachedEnd returns false when no duration set", () => {
const { clock, advance } = createClock();
clock.play();
advance(999999000);
expect(clock.reachedEnd()).toBe(false);
});
});
describe("snapshot", () => {
it("returns current state", () => {
const { clock, advance } = createClock({ duration: 10, rate: 1.5 });
clock.play();
advance(2000);
const snap = clock.snapshot();
expect(snap.time).toBe(3);
expect(snap.playing).toBe(true);
expect(snap.rate).toBe(1.5);
expect(snap.duration).toBe(10);
});
});
describe("edge cases", () => {
it("time never goes negative", () => {
const { clock } = createClock({ initialTime: 0 });
clock.seek(-100);
expect(clock.now()).toBe(0);
});
it("multiple play/pause cycles accumulate correctly", () => {
const { clock, advance } = createClock();
for (let i = 0; i < 100; i++) {
clock.play();
advance(10);
clock.pause();
}
expect(clock.now()).toBeCloseTo(1, 5);
});
it("seek to 0 while playing restarts from beginning", () => {
const { clock, advance } = createClock();
clock.play();
advance(5000);
clock.seek(0);
expect(clock.now()).toBe(0);
advance(1000);
expect(clock.now()).toBe(1);
});
});
describe("audio-master clock", () => {
function createMockAudioEl(currentTime: number, paused: boolean) {
return { currentTime, paused } as HTMLMediaElement;
}
it("reads time from audio element when attached and playing", () => {
const { clock } = createClock({ duration: 10 });
const audioEl = createMockAudioEl(3.5, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(3.5);
expect(clock.getSource()).toBe("audio");
});
it("falls back to monotonic when audio is paused", () => {
const { clock, advance } = createClock({ duration: 10 });
const audioEl = createMockAudioEl(3.5, true);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
advance(1000);
expect(clock.now()).toBe(1);
expect(clock.getSource()).toBe("monotonic");
});
it("accounts for compositionStart offset", () => {
const { clock } = createClock({ duration: 20 });
const audioEl = createMockAudioEl(2.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 5, mediaStart: 0 });
expect(clock.now()).toBe(7);
});
it("accounts for mediaStart offset", () => {
const { clock } = createClock({ duration: 20 });
const audioEl = createMockAudioEl(5.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 2 });
expect(clock.now()).toBe(3);
});
it("detaching preserves current time and falls back to monotonic", () => {
const { clock, advance } = createClock({ duration: 20 });
const audioEl = createMockAudioEl(5.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(5);
clock.detachAudioSource();
expect(clock.now()).toBeCloseTo(5, 1);
advance(1000);
expect(clock.now()).toBeCloseTo(6, 1);
expect(clock.getSource()).toBe("monotonic");
});
it("clamps audio-derived time to duration", () => {
const { clock } = createClock({ duration: 10 });
const audioEl = createMockAudioEl(15.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(10);
});
it("hasAudioSource returns correct state", () => {
const { clock } = createClock();
expect(clock.hasAudioSource()).toBe(false);
clock.attachAudioSource({
el: createMockAudioEl(0, false),
compositionStart: 0,
mediaStart: 0,
});
expect(clock.hasAudioSource()).toBe(true);
clock.detachAudioSource();
expect(clock.hasAudioSource()).toBe(false);
});
it("audio stall freezes visual time (desired behavior for narration)", () => {
const { clock } = createClock({ duration: 20 });
const audioEl = createMockAudioEl(3.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(3);
// Audio buffers — currentTime doesn't advance
expect(clock.now()).toBe(3);
expect(clock.now()).toBe(3);
// Audio resumes
audioEl.currentTime = 3.5;
expect(clock.now()).toBe(3.5);
});
});
});
+161
View File
@@ -0,0 +1,161 @@
export type TransportClockSnapshot = {
time: number;
playing: boolean;
rate: number;
duration: number;
source: "monotonic" | "audio";
};
export type AudioClockSource =
| {
el: HTMLMediaElement;
compositionStart: number;
mediaStart: number;
}
| {
currentTimeSeconds: number;
};
export class TransportClock {
private _baseTime = 0;
private _playStartMs: number | null = null;
private _rate = 1;
private _duration = Infinity;
private _nowMs: () => number;
private _audioSource: AudioClockSource | null = null;
constructor(opts?: {
initialTime?: number;
rate?: number;
duration?: number;
nowMs?: () => number;
}) {
this._baseTime = opts?.initialTime ?? 0;
this._rate = opts?.rate ?? 1;
this._duration = opts?.duration ?? Infinity;
this._nowMs = opts?.nowMs ?? (() => performance.now());
}
now(): number {
if (this._playStartMs === null) return this._baseTime;
// Audio-master: when an audio source is attached, derive time
// from it. Drift is impossible because audio IS the clock.
if (this._audioSource) {
let audioTime: number | null = null;
if ("currentTimeSeconds" in this._audioSource) {
audioTime = this._audioSource.currentTimeSeconds;
} else {
const { el, compositionStart, mediaStart } = this._audioSource;
if (!el.paused && Number.isFinite(el.currentTime)) {
audioTime = (el.currentTime - mediaStart) / this._rate + compositionStart;
}
}
if (audioTime !== null) {
if (Number.isFinite(this._duration) && audioTime >= this._duration) {
return this._duration;
}
return Math.max(0, audioTime);
}
}
// Monotonic fallback
const elapsed = (this._nowMs() - this._playStartMs) / 1000;
const t = this._baseTime + elapsed * this._rate;
if (Number.isFinite(this._duration) && t >= this._duration) {
return this._duration;
}
return Math.max(0, t);
}
play(): boolean {
if (this._playStartMs !== null) return false;
if (Number.isFinite(this._duration) && this._baseTime >= this._duration) return false;
this._playStartMs = this._nowMs();
return true;
}
pause(): boolean {
if (this._playStartMs === null) return false;
this._baseTime = this.now();
this._playStartMs = null;
return true;
}
seek(timeSeconds: number): void {
const clamped = Number.isFinite(this._duration)
? Math.max(0, Math.min(timeSeconds, this._duration))
: Math.max(0, timeSeconds);
this._baseTime = clamped;
if (this._playStartMs !== null) {
this._playStartMs = this._nowMs();
}
}
isPlaying(): boolean {
return this._playStartMs !== null;
}
setRate(rate: number): void {
const safe = Number.isFinite(rate) && rate > 0 ? Math.max(0.1, Math.min(5, rate)) : 1;
if (this._playStartMs !== null) {
this._baseTime = this.now();
this._playStartMs = this._nowMs();
}
this._rate = safe;
}
getRate(): number {
return this._rate;
}
setDuration(duration: number): void {
this._duration = Number.isFinite(duration) && duration > 0 ? duration : Infinity;
if (this._baseTime > this._duration) {
this._baseTime = this._duration;
}
}
getDuration(): number {
return this._duration;
}
attachAudioSource(source: AudioClockSource): void {
this._audioSource = source;
}
detachAudioSource(): void {
if (this._audioSource && this._playStartMs !== null) {
this._baseTime = this.now();
this._playStartMs = this._nowMs();
}
this._audioSource = null;
}
hasAudioSource(): boolean {
return this._audioSource !== null;
}
getSource(): "monotonic" | "audio" {
if (this._audioSource && this._playStartMs !== null) {
if ("currentTimeSeconds" in this._audioSource) return "audio";
const { el } = this._audioSource;
if (!el.paused && Number.isFinite(el.currentTime)) return "audio";
}
return "monotonic";
}
snapshot(): TransportClockSnapshot {
return {
time: this.now(),
playing: this.isPlaying(),
rate: this._rate,
duration: this._duration,
source: this.getSource(),
};
}
reachedEnd(): boolean {
return Number.isFinite(this._duration) && this.now() >= this._duration;
}
}
+345 -92
View File
@@ -14,6 +14,9 @@ import { collectRuntimeTimelinePayload } from "./timeline";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { applyCaptionOverrides } from "./captionOverrides";
import { TransportClock } from "./clock";
import { WebAudioTransport } from "./webAudioTransport";
import { quantizeTimeToFrame } from "../inline-scripts/parityContract";
import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types";
import type { PlayerAPI } from "../core.types";
import { swallow } from "./diagnostics";
@@ -153,10 +156,6 @@ export function initSandboxRuntimeModular(): void {
const MIN_VALID_TIMELINE_DURATION_SECONDS = 1 / 60;
const TIMELINE_FLOOR_COVERAGE_RATIO = 0.75;
const LOOP_WRAP_PREVIOUS_TIME_THRESHOLD_SECONDS = 0.75;
const LOOP_WRAP_CURRENT_TIME_THRESHOLD_SECONDS = 0.35;
const LOOP_GUARD_SEEK_GRACE_PERIOD_MS = 900;
const LOOP_GUARD_CONSECUTIVE_WRAP_THRESHOLD = 3;
const PLAY_REBIND_HOLD_SECONDS = 2;
const METADATA_REBIND_MIN_DURATION_GAIN_SECONDS = 0.05;
const METADATA_REBIND_DEBOUNCE_MS = 100;
@@ -905,14 +904,6 @@ export function initSandboxRuntimeModular(): void {
return { timeline: null };
};
const syncCurrentTimeFromTimeline = () => {
const timeline = state.capturedTimeline;
if (!timeline || typeof timeline.time !== "function") return;
const nextTime = Number(timeline.time());
if (!Number.isFinite(nextTime)) return;
state.currentTime = Math.max(0, nextTime);
};
// Track whether child composition timelines have been added to the root.
// This prevents the polling loop from skipping rebind when TARGET_DURATION
// makes the root "usable" before children register. Assumption: child scripts
@@ -1297,6 +1288,8 @@ export function initSandboxRuntimeModular(): void {
return sourceDuration ?? hostRemaining;
},
});
const forceSync = state.mediaForceSyncNextTick;
if (forceSync) state.mediaForceSyncNextTick = false;
syncRuntimeMedia({
clips: cache.mediaClips,
timeSeconds: state.currentTime,
@@ -1305,6 +1298,7 @@ export function initSandboxRuntimeModular(): void {
outputMuted: state.mediaOutputMuted,
userMuted: state.bridgeMuted,
userVolume: state.bridgeVolume,
forceSync,
onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true;
@@ -1363,7 +1357,6 @@ export function initSandboxRuntimeModular(): void {
};
const postState = (force: boolean) => {
syncCurrentTimeFromTimeline();
const frame = Math.max(0, Math.round((state.currentTime || 0) * state.canonicalFps));
const now = Date.now();
const shouldPost =
@@ -1481,6 +1474,7 @@ export function initSandboxRuntimeModular(): void {
} else {
state.playbackRate = Math.max(0.1, Math.min(5, parsed));
}
state.mediaForceSyncNextTick = true;
if (state.capturedTimeline && typeof state.capturedTimeline.timeScale === "function") {
state.capturedTimeline.timeScale(state.playbackRate);
}
@@ -1505,6 +1499,7 @@ export function initSandboxRuntimeModular(): void {
(window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
getIsPlaying: () => state.isPlaying,
setIsPlaying: (playing) => {
if (state.isPlaying !== playing) state.mediaForceSyncNextTick = true;
state.isPlaying = playing;
},
getPlaybackRate: () => state.playbackRate,
@@ -1512,6 +1507,7 @@ export function initSandboxRuntimeModular(): void {
getCanonicalFps: () => state.canonicalFps,
onSyncMedia: (timeSeconds, playing) => {
state.currentTime = Math.max(0, Number(timeSeconds) || 0);
if (state.isPlaying !== playing) state.mediaForceSyncNextTick = true;
state.isPlaying = playing;
syncMediaForCurrentState();
},
@@ -1562,6 +1558,7 @@ export function initSandboxRuntimeModular(): void {
onSetMuted: (muted) => {
state.bridgeMuted = muted;
const effective = muted || state.mediaOutputMuted;
webAudio.setMuted(effective);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
@@ -1570,6 +1567,7 @@ export function initSandboxRuntimeModular(): void {
},
onSetVolume: (volume) => {
state.bridgeVolume = volume;
webAudio.setVolume(volume);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
@@ -1581,13 +1579,17 @@ export function initSandboxRuntimeModular(): void {
onSetMediaOutputMuted: (muted) => {
state.mediaOutputMuted = muted;
const effective = muted || state.bridgeMuted;
webAudio.setMuted(effective);
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective;
}
},
onSetPlaybackRate: (rate) => applyPlaybackRate(rate),
onSetPlaybackRate: (rate) => {
applyPlaybackRate(rate);
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
},
onEnablePickMode: () => picker.enablePickMode(),
onDisablePickMode: () => picker.disablePickMode(),
});
@@ -1627,100 +1629,351 @@ export function initSandboxRuntimeModular(): void {
installRuntimeErrorDiagnostics();
runAdapters("discover");
bindMediaMetadataListeners();
if (state.timelinePollIntervalId) {
clearInterval(state.timelinePollIntervalId);
}
let timelinePollTick = 0;
let lastObservedTimelineTime: number | null = null;
let lastExplicitSeekAtMs = 0;
let loopGuardRebindTriggered = false;
let loopWrapCandidateCount = 0;
const markExplicitSeek = () => {
lastExplicitSeekAtMs = Date.now();
loopGuardRebindTriggered = false;
loopWrapCandidateCount = 0;
// ── 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;
});
let transportTickCount = 0;
let inTransportTick = false;
const seekTimelineAndAdapters = (t: number) => {
const tl = state.capturedTimeline;
if (tl) {
try {
if (typeof tl.totalTime === "function") {
tl.totalTime(t, false);
} else {
tl.seek(t, false);
}
} catch (err) {
swallow("runtime.init.transport.seek", err);
}
// Sibling timelines (registered in __timelines but not nested under
// the root) are paused alongside the master. We do NOT seek them to
// absolute position `t` here — child timelines nested under the root
// are already propagated via tl.totalTime(), and seeking them again
// at absolute `t` would clobber their offset-relative position.
// Play/pause propagation for siblings happens in the player.play()
// and player.pause() overrides via the adapter layer.
}
for (const adapter of state.deterministicAdapters) {
try {
adapter.seek({ time: t });
} catch (err) {
swallow("runtime.init.transport.adapter", err);
}
}
};
state.timelinePollIntervalId = setInterval(() => {
timelinePollTick += 1;
const shouldHoldRebindDuringEarlyPlay =
state.isPlaying &&
state.capturedTimeline != null &&
Math.max(0, state.currentTime || 0) < PLAY_REBIND_HOLD_SECONDS;
const timelineBoundThisTick = shouldHoldRebindDuringEarlyPlay
? false
: bindRootTimelineIfAvailable();
if (state.capturedTimeline && !player._timeline) {
player._timeline = state.capturedTimeline;
}
if (timelineBoundThisTick || timelinePollTick % 20 === 0) {
postTimeline();
}
if (timelinePollTick % 10 === 0) {
bindMediaMetadataListeners();
}
syncCurrentTimeFromTimeline();
if (state.isPlaying && state.capturedTimeline) {
const currentObserved = Math.max(0, state.currentTime || 0);
const previousObserved = lastObservedTimelineTime;
const safeDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (safeDuration > 0 && currentObserved >= safeDuration) {
player.pause();
player.seek(safeDuration);
lastObservedTimelineTime = safeDuration;
loopWrapCandidateCount = 0;
const transportTick = () => {
if (state.tornDown || inTransportTick) return;
inTransportTick = true;
try {
state.transportRafId = window.requestAnimationFrame(transportTick);
transportTickCount += 1;
// Slower operations: timeline binding (~every 60 frames / ~1s at 60fps)
if (transportTickCount % 60 === 0) {
const shouldHoldRebind =
clock.isPlaying() &&
state.capturedTimeline != null &&
clock.now() < PLAY_REBIND_HOLD_SECONDS;
if (!shouldHoldRebind) {
const prevTimeline = state.capturedTimeline;
if (bindRootTimelineIfAvailable()) {
if (state.capturedTimeline && !player._timeline) {
player._timeline = state.capturedTimeline;
}
if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) {
state.capturedTimeline.pause();
}
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (dur > 0) clock.setDuration(dur);
postTimeline();
}
}
}
if (transportTickCount % 20 === 0) {
postTimeline();
}
if (transportTickCount % 30 === 0) {
bindMediaMetadataListeners();
}
// Keep clock duration in sync with the resolved timeline duration.
// Cheap (no DOM reads) and catches async timeline rebinds that happen
// outside the 60-tick branch (metadata hydration, deferred setTimeout).
if (state.capturedTimeline) {
const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (dur > 0) clock.setDuration(dur);
}
// Audio-master clock: three tiers of timing precision.
// 1. WebAudio (AudioContext.currentTime): ~21µs, sample-accurate
// 2. HTMLMediaElement (audio.currentTime): ~33ms, frame-accurate
// 3. Monotonic (performance.now()): ~1ms, no audio coupling
if (clock.isPlaying() && !state.mediaOutputMuted) {
if (webAudio.isActive() && webAudio.context) {
const webAudioTime = webAudio.getTime();
if (webAudioTime >= 0) {
clock.attachAudioSource({ currentTimeSeconds: webAudioTime });
}
} else {
const audioEls = document.querySelectorAll("audio[data-start]");
let foundActive = false;
for (const rawEl of audioEls) {
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
const start = Number.parseFloat(rawEl.dataset.start ?? "");
const durAttr = Number.parseFloat(rawEl.dataset.duration ?? "");
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
const mediaStart =
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") ||
0;
if (Number.isFinite(start) && state.currentTime >= start && state.currentTime < end) {
if (!rawEl.paused) {
clock.attachAudioSource({ el: rawEl, compositionStart: start, mediaStart });
foundActive = true;
} else if (rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
// Audio is buffering — freeze visuals at last known position
// instead of falling through to monotonic (which runs ahead).
clock.attachAudioSource({ currentTimeSeconds: state.currentTime });
foundActive = true;
}
break;
}
}
if (!foundActive && clock.hasAudioSource()) {
clock.detachAudioSource();
}
}
} else if (clock.hasAudioSource()) {
clock.detachAudioSource();
}
const t = clock.now();
state.currentTime = t;
seekTimelineAndAdapters(t);
// Looping is handled at the player layer (<hyperframes-player>),
// not the runtime. The clock pauses at duration; GSAP's repeat:-1
// is bypassed because we drive tl.totalTime(t) directly. The
// parent observes isPlaying=false at end and re-issues seek(0)+play()
// if its loop attribute is set.
if (clock.isPlaying() && clock.reachedEnd()) {
webAudio.stopAll();
clock.detachAudioSource();
clock.pause();
state.isPlaying = false;
const dur = clock.getDuration();
if (Number.isFinite(dur)) {
clock.seek(dur);
state.currentTime = dur;
seekTimelineAndAdapters(dur);
}
runAdapters("pause");
syncMediaForCurrentState();
postState(true);
return;
}
const isWrapCandidate =
previousObserved != null &&
previousObserved >= LOOP_WRAP_PREVIOUS_TIME_THRESHOLD_SECONDS &&
currentObserved <= LOOP_WRAP_CURRENT_TIME_THRESHOLD_SECONDS;
if (isWrapCandidate) {
loopWrapCandidateCount += 1;
} else {
loopWrapCandidateCount = 0;
if (clock.isPlaying()) {
syncMediaForCurrentState();
}
if (
!loopGuardRebindTriggered &&
loopWrapCandidateCount >= LOOP_GUARD_CONSECUTIVE_WRAP_THRESHOLD &&
Date.now() - lastExplicitSeekAtMs > LOOP_GUARD_SEEK_GRACE_PERIOD_MS
) {
const resolution = resolveRootTimelineFromDocument();
if (rebindTimelineFromResolution(resolution, "loop_guard")) {
loopGuardRebindTriggered = true;
loopWrapCandidateCount = 0;
postState(false);
} finally {
inTransportTick = false;
}
};
const hardSyncAllMedia = (timeSeconds: number) => {
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
if (!el.isConnected) continue;
const start = Number.parseFloat(el.dataset.start ?? "");
if (!Number.isFinite(start)) continue;
const durAttr = Number.parseFloat(el.dataset.duration ?? "");
const end = Number.isFinite(durAttr) && durAttr > 0 ? start + durAttr : Infinity;
if (timeSeconds < start || timeSeconds >= end) continue;
const mediaStart =
Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
const relTime = timeSeconds - start + mediaStart;
if (relTime >= 0) {
try {
el.currentTime = relTime;
} catch {
// ignore seek restrictions
}
}
lastObservedTimelineTime = Math.max(0, state.currentTime || 0);
} else {
lastObservedTimelineTime = Math.max(0, state.currentTime || 0);
}
if (state.isPlaying) {
syncMediaForCurrentState();
};
// Player methods route through the TransportClock.
player.play = () => {
const tl = state.capturedTimeline;
if (!tl || 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);
}
}
postState(false);
}, 50);
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) {
const gen = webAudio.startGeneration();
const audioEls = document.querySelectorAll("audio[data-start]");
for (const rawEl of audioEls) {
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
if (!Number.isFinite(compStart)) continue;
const mediaStart =
Number.parseFloat(rawEl.dataset.playbackStart ?? rawEl.dataset.mediaStart ?? "0") || 0;
const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? "");
const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1;
void webAudio.decodeAudioElement(rawEl).then((buffer) => {
if (!buffer || !clock.isPlaying()) return;
void webAudio.schedulePlayback(
rawEl,
buffer,
compStart,
mediaStart,
clock.now(),
vol * state.bridgeVolume,
gen,
);
});
}
}
runAdapters("play");
syncMediaForCurrentState();
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();
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();
postState(true);
};
player.renderSeek = (timeSeconds: number) => {
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);
syncMediaForCurrentState();
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);
};
// 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],
configurable: true,
});
}
}
// Start the rAF tick loop
state.transportRafId = window.requestAnimationFrame(transportTick);
postTimeline();
postState(true);
const originalSeek = player.seek;
player.seek = (timeSeconds: number) => {
markExplicitSeek();
originalSeek(timeSeconds);
};
const originalRenderSeek = player.renderSeek;
player.renderSeek = (timeSeconds: number) => {
markExplicitSeek();
originalRenderSeek(timeSeconds);
};
const teardown = () => {
if (state.tornDown) return;
state.tornDown = true;
if (state.timelinePollIntervalId) {
clearInterval(state.timelinePollIntervalId);
state.timelinePollIntervalId = null;
if (state.transportRafId != null) {
window.cancelAnimationFrame(state.transportRafId);
state.transportRafId = null;
}
state.transportClock = null;
webAudio.destroy();
if (metadataRebindDebounceTimerId != null) {
window.clearTimeout(metadataRebindDebounceTimerId);
metadataRebindDebounceTimerId = null;
+36
View File
@@ -642,6 +642,42 @@ describe("syncRuntimeMedia", () => {
expect(posted).toBe(1);
});
it("corrects stable sub-0.5s drift after consecutive over-threshold ticks", () => {
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
Object.defineProperty(clip.el, "currentTime", { value: 5.4, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 5.4, playing: true, playbackRate: 1 });
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(5.4);
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(5.4);
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
expect(clip.el.currentTime).toBe(5);
});
it("does not force audio forward while it's still buffering (gradual drift growth)", () => {
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 0, playing: true, playbackRate: 1 });
for (let t = 0.016; t < 0.7; t += 0.016) {
syncRuntimeMedia({ clips: [clip], timeSeconds: t, playing: true, playbackRate: 1 });
}
expect(clip.el.currentTime).toBe(0);
});
it("forceSync corrects any drift above 20ms immediately", () => {
const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 });
Object.defineProperty(clip.el, "currentTime", { value: 5.1, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 5.1, playing: true, playbackRate: 1 });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5,
playing: true,
playbackRate: 1,
forceSync: true,
});
expect(clip.el.currentTime).toBe(5);
});
it("mutes when either outputMuted OR userMuted is true (OR invariant)", () => {
// Explicit validation of the combined-flag contract: setting one to
// false while the other is true must keep the element muted.
+40 -30
View File
@@ -79,6 +79,8 @@ export function refreshRuntimeMediaCache(params?: {
// inactive so the next activation gets a hard resync on its first tick.
const lastOffset = new WeakMap<HTMLMediaElement, number>();
const strictDriftSamples = new WeakMap<HTMLMediaElement, number>();
// Elements that had a seek past their buffered range (common with streaming
// MP3 where preload="metadata" only fetches the first few seconds). After
// setting preload="auto" and calling load(), we mark the element so subsequent
@@ -130,6 +132,7 @@ export function syncRuntimeMedia(params: {
* outbound message; further invocations are suppressed by the caller.
*/
onAutoplayBlocked?: () => void;
forceSync?: boolean;
}): void {
// Either flag silences output. Combined up front so the per-clip loop is
// a single branch instead of two.
@@ -165,26 +168,27 @@ export function syncRuntimeMedia(params: {
// ignore unsupported playbackRate
swallow("runtime.media.site1", err);
}
// Drift correction. Forcing `el.currentTime = relTime` every frame
// causes an audible seek+rebuffer hiccup (readyState drops briefly).
// Drift correction — three tiers:
//
// We only want to correct drift that came from an *event* — an explicit
// user seek, a sub-composition activation, or a timeline jump — not
// drift that grew naturally from initial-buffer latency. Telling them
// apart by timing: scrubs move the timeline-to-media offset by seconds
// in a single tick; buffer catch-up grows the offset by ~one frame
// (<20ms) per tick.
// 1. Hard sync (0.5s): first tick, timeline jumps (scrub), catastrophic
// drift (>3s). Unconditional seek — accepts brief rebuffer cost.
// Forcing el.currentTime every frame causes audible seek hiccups
// (readyState drops briefly), so we only hard-seek when necessary.
//
// The first tick a clip is active we don't have a previous offset to
// compare against — treat that as a hard resync so sub-compositions
// with non-zero `mediaStart` land on the right frame.
// 2. Strict sync (40ms, 2 consecutive samples): catches accumulated
// drift from pause/play toggling or browser media pipeline latency.
// Offset-stabilization guard (4ms/tick) prevents false corrections
// during initial buffering where offset grows naturally.
//
// Tradeoff: the 3 s catastrophic-drift valve means an unnoticed
// steady-state drift can accumulate up to ~3 s before we correct.
// For music / motion graphics this is inaudible; for lip-synced
// dialogue it is not. If that becomes a target use case, switch to
// a short-window tight threshold (e.g. tighten to 0.15 s when the
// last play/pause transition was >500 ms ago).
// 3. Force sync (20ms): on play/pause/seek/rate transitions, correct
// any drift >20ms immediately via the forceSync one-shot flag.
//
// The first tick a clip is active has no previous offset to compare —
// treated as hard resync so sub-compositions with non-zero mediaStart
// land on the right frame.
const STRICT_DRIFT_THRESHOLD = 0.04;
const STRICT_REQUIRED_SAMPLES = 2;
const currentElTime = el.currentTime || 0;
const drift = Math.abs(currentElTime - relTime);
const offset = relTime - currentElTime;
@@ -193,33 +197,38 @@ export function syncRuntimeMedia(params: {
const firstTickOfClip = prevOffset === undefined;
const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5;
const catastrophicDrift = drift > 3;
if (drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift)) {
const hardSync = drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift);
// Only apply strict sync when offset has stabilized (not growing).
// During initial buffering, offset grows ~16ms/tick as the timeline
// advances while media stays at 0. Accumulated drift from pause/play
// toggling shows up as a stable, non-zero offset (delta near 0).
const offsetStabilized = prevOffset !== undefined && Math.abs(offset - prevOffset) < 0.004;
let strictSync = false;
if (!hardSync && !firstTickOfClip && offsetStabilized && drift > STRICT_DRIFT_THRESHOLD) {
const samples = (strictDriftSamples.get(el) ?? 0) + 1;
strictDriftSamples.set(el, samples);
if (samples >= STRICT_REQUIRED_SAMPLES) {
strictSync = true;
strictDriftSamples.set(el, 0);
}
} else if (drift <= STRICT_DRIFT_THRESHOLD) {
strictDriftSamples.set(el, 0);
}
if (hardSync || strictSync || (params.forceSync && drift > 0.02)) {
try {
el.currentTime = relTime;
} catch (err) {
// ignore browser seek restrictions
swallow("runtime.media.site2", err);
}
// Detect failed seek: if currentTime didn't reach the target,
// the browser can't seek past its buffered range. Common with
// streaming MP3 where only the first ~15s is cached. Force a
// full network fetch via load() so the browser builds a complete
// media index. One-shot per element — subsequent sync ticks will
// re-attempt the seek once data arrives.
if (Math.abs(el.currentTime - relTime) > 0.5 && !seekLoadRetried.has(el)) {
seekLoadRetried.add(el);
el.load();
try {
el.currentTime = relTime;
} catch (err) {
// ignore — the seek will be retried on the next tick
swallow("runtime.media.site3", err);
}
}
// After a hard seek, clear the in-flight play guard so the next tick
// can re-issue play(). Without this, a seek during playback leaves
// the element paused at the new position for 50-150ms (one poll
// interval) while the timeline continues — audible desync on scrub.
playRequested.delete(el);
}
if (params.playing && el.paused && !playRequested.has(el)) {
@@ -258,6 +267,7 @@ export function syncRuntimeMedia(params: {
// Clip left its active window — drop the offset baseline so the next
// activation (e.g. re-entering a sub-composition) gets a hard resync.
lastOffset.delete(el);
strictDriftSamples.delete(el);
seekLoadRetried.delete(el);
if (!el.paused) el.pause();
}
+19 -2
View File
@@ -1,5 +1,6 @@
import type { RuntimeDeterministicAdapter, RuntimeTimelineLike } from "./types";
import type { RuntimeMediaClip } from "./media";
import type { TransportClock } from "./clock";
export type RuntimeState = {
capturedTimeline: RuntimeTimelineLike | null;
@@ -26,6 +27,13 @@ export type RuntimeState = {
* takes over playback and further rejections are the same problem.
*/
mediaAutoplayBlockedPosted: boolean;
/**
* One-shot flag: force a hard media sync on the next tick. Set on
* play/pause/seek/rate transitions to immediately correct any
* accumulated sub-threshold drift from pause/play toggling.
* Consumed (reset to false) by `syncMediaForCurrentState`.
*/
mediaForceSyncNextTick: boolean;
playbackRate: number;
bridgeLastPostedFrame: number;
bridgeLastPostedAt: number;
@@ -53,7 +61,6 @@ export type RuntimeState = {
* silently push correction latency past the tolerance budget.
*/
bridgeMaxPostIntervalMs: number;
timelinePollIntervalId: ReturnType<typeof setInterval> | null;
controlBridgeHandler: ((event: MessageEvent) => void) | null;
clampDurationLoggedRaw: number | null;
beforeUnloadHandler: (() => void) | null;
@@ -67,6 +74,14 @@ export type RuntimeState = {
tornDown: boolean;
maxTimelineDurationSeconds: number;
nativeVisualWatchdogTick: number;
/**
* Single-clock transport. The sole time authority GSAP is always
* paused and seeked to `clock.now()` on each rAF tick. Eliminates
* the two-clock drift problem described in issue #668.
*/
transportClock: TransportClock | null;
/** rAF ID for the single-clock tick loop. */
transportRafId: number | null;
};
export function createRuntimeState(): RuntimeState {
@@ -82,13 +97,13 @@ export function createRuntimeState(): RuntimeState {
bridgeVolume: 1,
mediaOutputMuted: false,
mediaAutoplayBlockedPosted: false,
mediaForceSyncNextTick: false,
playbackRate: 1,
bridgeLastPostedFrame: -1,
bridgeLastPostedAt: 0,
bridgeLastPostedPlaying: false,
bridgeLastPostedMuted: false,
bridgeMaxPostIntervalMs: 80,
timelinePollIntervalId: null,
controlBridgeHandler: null,
clampDurationLoggedRaw: null,
beforeUnloadHandler: null,
@@ -102,5 +117,7 @@ export function createRuntimeState(): RuntimeState {
tornDown: false,
maxTimelineDurationSeconds: 1800,
nativeVisualWatchdogTick: 0,
transportClock: null,
transportRafId: null,
};
}
@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from "vitest";
import { WebAudioTransport } from "./webAudioTransport";
describe("WebAudioTransport", () => {
it("tracks play generation for async race prevention", () => {
const transport = new WebAudioTransport();
expect(transport.currentGeneration()).toBe(0);
const gen1 = transport.startGeneration();
expect(gen1).toBe(1);
const gen2 = transport.startGeneration();
expect(gen2).toBe(2);
expect(transport.currentGeneration()).toBe(2);
});
it("getTime returns -1 when paused", () => {
const transport = new WebAudioTransport();
expect(transport.getTime()).toBe(-1);
});
it("isActive returns false initially", () => {
const transport = new WebAudioTransport();
expect(transport.isActive()).toBe(false);
});
it("stopAll restores el.muted to prior value", () => {
const transport = new WebAudioTransport();
const mockEl = { muted: false } as HTMLMediaElement;
const mockSource = {
el: mockEl,
sourceNode: { stop: vi.fn(), disconnect: vi.fn() } as unknown as AudioBufferSourceNode,
gainNode: { disconnect: vi.fn() } as unknown as GainNode,
compositionStart: 0,
mediaStart: 0,
scheduledAt: 0,
priorMuted: false,
};
// Simulate WebAudio taking over: el.muted was set to true
mockEl.muted = true;
(transport as unknown as { _activeSources: (typeof mockSource)[] })._activeSources = [
mockSource,
];
(transport as unknown as { _paused: boolean })._paused = false;
expect(transport.isActive()).toBe(true);
transport.stopAll();
expect(mockEl.muted).toBe(false);
expect(transport.isActive()).toBe(false);
});
it("stopAll restores el.muted=true when element was already muted", () => {
const transport = new WebAudioTransport();
const mockEl = { muted: true } as HTMLMediaElement;
const mockSource = {
el: mockEl,
sourceNode: { stop: vi.fn(), disconnect: vi.fn() } as unknown as AudioBufferSourceNode,
gainNode: { disconnect: vi.fn() } as unknown as GainNode,
compositionStart: 0,
mediaStart: 0,
scheduledAt: 0,
priorMuted: true,
};
(transport as unknown as { _activeSources: (typeof mockSource)[] })._activeSources = [
mockSource,
];
transport.stopAll();
expect(mockEl.muted).toBe(true);
});
it("stopAll called multiple times is safe (idempotent)", () => {
const transport = new WebAudioTransport();
transport.stopAll();
transport.stopAll();
expect(transport.isActive()).toBe(false);
});
it("destroy clears buffer cache and nulls context", () => {
const transport = new WebAudioTransport();
transport.destroy();
expect(transport.context).toBeNull();
expect(transport.isActive()).toBe(false);
});
});
@@ -0,0 +1,164 @@
import { swallow } from "./diagnostics";
export type ScheduledSource = {
el: HTMLMediaElement;
sourceNode: AudioBufferSourceNode;
gainNode: GainNode;
compositionStart: number;
mediaStart: number;
scheduledAt: number;
priorMuted: boolean;
};
export class WebAudioTransport {
private _ctx: AudioContext | null = null;
private _bufferCache = new Map<string, AudioBuffer>();
private _activeSources: ScheduledSource[] = [];
private _masterGain: GainNode | null = null;
private _scheduleOffset = 0;
private _paused = true;
private _playGeneration = 0;
async init(): Promise<boolean> {
try {
this._ctx = new AudioContext();
this._masterGain = this._ctx.createGain();
this._masterGain.connect(this._ctx.destination);
return true;
} catch {
return false;
}
}
get context(): AudioContext | null {
return this._ctx;
}
getTime(): number {
if (!this._ctx || this._paused) return -1;
return this._ctx.currentTime - this._scheduleOffset;
}
async decodeAudioElement(el: HTMLMediaElement): Promise<AudioBuffer | null> {
const src = el.currentSrc || el.getAttribute("src");
if (!src) return null;
if (this._bufferCache.has(src)) return this._bufferCache.get(src)!;
if (!this._ctx) return null;
try {
const response = await fetch(src);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this._ctx.decodeAudioData(arrayBuffer);
this._bufferCache.set(src, audioBuffer);
return audioBuffer;
} catch (err) {
swallow("webAudioTransport.decode", err);
return null;
}
}
startGeneration(): number {
this._playGeneration += 1;
return this._playGeneration;
}
currentGeneration(): number {
return this._playGeneration;
}
async schedulePlayback(
el: HTMLMediaElement,
buffer: AudioBuffer,
compositionStart: number,
mediaStart: number,
compositionTime: number,
volume: number,
generation: number,
): Promise<ScheduledSource | null> {
if (!this._ctx || !this._masterGain) return null;
if (generation !== this._playGeneration) return null;
try {
if (this._ctx.state === "suspended") {
await this._ctx.resume();
}
if (generation !== this._playGeneration) return null;
const sourceNode = this._ctx.createBufferSource();
sourceNode.buffer = buffer;
const gainNode = this._ctx.createGain();
gainNode.gain.value = volume;
sourceNode.connect(gainNode);
gainNode.connect(this._masterGain);
const offset = Math.max(0, compositionTime - compositionStart + mediaStart);
const scheduledAt = this._ctx.currentTime;
this._scheduleOffset = scheduledAt - compositionTime;
sourceNode.start(0, offset);
const priorMuted = el.muted;
el.muted = true;
const scheduled: ScheduledSource = {
el,
sourceNode,
gainNode,
compositionStart,
mediaStart,
scheduledAt,
priorMuted,
};
this._activeSources.push(scheduled);
this._paused = false;
return scheduled;
} catch (err) {
swallow("webAudioTransport.schedule", err);
return null;
}
}
stopAll(): void {
for (const source of this._activeSources) {
try {
source.sourceNode.stop();
source.sourceNode.disconnect();
source.gainNode.disconnect();
} catch {
// already stopped
}
source.el.muted = source.priorMuted;
}
this._activeSources = [];
this._paused = true;
}
setVolume(volume: number): void {
if (this._masterGain) {
this._masterGain.gain.value = Math.max(0, Math.min(1, volume));
}
}
setMuted(muted: boolean): void {
if (this._masterGain) {
this._masterGain.gain.value = muted ? 0 : 1;
}
}
isActive(): boolean {
return this._activeSources.length > 0 && !this._paused;
}
destroy(): void {
this.stopAll();
this._bufferCache.clear();
if (this._ctx) {
try {
void this._ctx.close();
} catch {
// ignore
}
}
this._ctx = null;
this._masterGain = null;
}
}