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
95 changes: 49 additions & 46 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3941,9 +3941,6 @@ export function initSandboxRuntimeModular(): void {
activateChildren: true,
suppressEvents: options?.suppressEvents,
});
// The explicit seek owns this paused frame; the transport must not redraw after capture waits.
lastTransportSeekTime = state.currentTime;
lastTransportSeekTimeline = state.capturedTimeline;
runAdapters("pause", 0, pageAnimations);
syncMediaForCurrentState();
colorGrading.redraw();
Expand Down Expand Up @@ -4186,10 +4183,8 @@ export function initSandboxRuntimeModular(): void {
}
let transportTickCount = 0;
let inTransportTick = false;
// A paused transport has no new frame to render. Re-seeking the same GSAP timeline at the
// same time on every rAF is not merely redundant: one picker can embed several paused
// players, multiplying full timeline traversal and style invalidation across every iframe.
// Keep enough identity to render once when time or the asynchronously-bound timeline changes.
// Reuse a completed seek only for the exact requested time and captured timeline.
// Paused players and host ticks can share the frame without suppressing a newer time.
let lastTransportSeekTime = Number.NaN;
let lastTransportSeekTimeline: RuntimeTimelineLike | null = null;
let pausedSeekDeferredByManualGesture = false;
Expand Down Expand Up @@ -4339,7 +4334,10 @@ export function initSandboxRuntimeModular(): void {
// play(), so without the rearm it holds its initial CSS state (opacity:0).
const rearmed = tl && opts?.activateChildren ? activateSiblingTimelines(tl) : [];
try {
return seekRootChildrenAndAdapters(tl, t, opts);
const animations = seekRootChildrenAndAdapters(tl, t, opts);
lastTransportSeekTime = t;
lastTransportSeekTimeline = tl;
return animations;
} finally {
for (const sibling of rearmed) pauseTimelineIfPossible(sibling);
}
Expand Down Expand Up @@ -4575,6 +4573,44 @@ export function initSandboxRuntimeModular(): void {
return longest;
};

function refreshTransportClockSource(): void {
// Audio-master clock: three tiers of timing precision.
// 1. WebAudio (AudioContext.currentTime) while it plays a decoded buffer: ~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 (
!state.nativeMediaSyncDisabled &&
!state.webAudioMediaDisabled &&
webAudio.ownsClock() &&
webAudio.context
) {
const webAudioTime = webAudio.getTime();
if (webAudioTime >= 0) {
clock.attachAudioSource({ currentTimeSeconds: webAudioTime });
}
} else {
const source = followedOrLongestRunningAudio(clock.audioElement());
if (source && !source.el.paused) {
clock.attachAudioSource({
el: source.el,
compositionStart: source.start,
mediaStart: readElementPlaybackStart(source.el),
rate: readElementRateSpec(source.el),
});
} else if (source && source.el.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 });
} else if (clock.hasAudioSource()) {
clock.detachAudioSource();
}
}
} else if (clock.hasAudioSource()) {
clock.detachAudioSource();
}
}

const transportTick = () => {
if (state.tornDown || inTransportTick) return;
inTransportTick = true;
Expand Down Expand Up @@ -4665,41 +4701,7 @@ export function initSandboxRuntimeModular(): void {
}
}

// Audio-master clock: three tiers of timing precision.
// 1. WebAudio (AudioContext.currentTime) while it plays a decoded buffer: ~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 (
!state.nativeMediaSyncDisabled &&
!state.webAudioMediaDisabled &&
webAudio.ownsClock() &&
webAudio.context
) {
const webAudioTime = webAudio.getTime();
if (webAudioTime >= 0) {
clock.attachAudioSource({ currentTimeSeconds: webAudioTime });
}
} else {
const source = followedOrLongestRunningAudio(clock.audioElement());
if (source && !source.el.paused) {
clock.attachAudioSource({
el: source.el,
compositionStart: source.start,
mediaStart: readElementPlaybackStart(source.el),
rate: readElementRateSpec(source.el),
});
} else if (source && source.el.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 });
} else if (clock.hasAudioSource()) {
clock.detachAudioSource();
}
}
} else if (clock.hasAudioSource()) {
clock.detachAudioSource();
}
refreshTransportClockSource();

const t = clock.now();
state.currentTime = t;
Expand All @@ -4723,8 +4725,6 @@ export function initSandboxRuntimeModular(): void {
state.capturedTimeline !== lastTransportSeekTimeline
) {
seekTimelineAndAdapters(t);
lastTransportSeekTime = t;
lastTransportSeekTimeline = state.capturedTimeline;
if (!isPlaying) pausedSeekDeferredByManualGesture = false;
}
if (isPlaying) {
Expand Down Expand Up @@ -5042,9 +5042,12 @@ export function initSandboxRuntimeModular(): void {
},
onTick: () => {
if (state.tornDown || !clock.isPlaying()) return;
refreshTransportClockSource();
const t = clock.now();
state.currentTime = t;
seekTimelineAndAdapters(t);
if (t !== lastTransportSeekTime || state.capturedTimeline !== lastTransportSeekTimeline) {
seekTimelineAndAdapters(t);
}
if (clock.reachedEnd()) {
webAudio.stopAll();
clock.detachAudioSource();
Expand Down
142 changes: 142 additions & 0 deletions packages/core/src/runtime/transportPark.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initSandboxRuntimeModular } from "./init";
import { WebAudioTransport } from "./webAudioTransport";
import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers";
import type { RuntimeTimelineLike } from "./types";

Expand Down Expand Up @@ -328,6 +329,147 @@ describe("parked transport loop", () => {
expect(raf.pending()).toBeGreaterThan(0);
});

/** The host's per-frame tick, as the player posts it on the runtime-bridge path. */
const hostTick = () =>
window.dispatchEvent(
new MessageEvent("message", {
source: window.parent,
data: { source: "hf-parent", type: "control", action: "tick" },
}),
);

const countSeeks = () => {
const timeline = window.__timelines!["main"] as RuntimeTimelineLike;
const seeks: number[] = [];
const originalTotalTime = timeline.totalTime!.bind(timeline);
timeline.totalTime = (time?: number, suppress?: boolean) => {
if (time !== undefined) seeks.push(time);
return originalTotalTime(time, suppress);
};
return seeks;
};

it("does not seek a timeline time already rendered by its frame loop", () => {
vi.spyOn(performance, "now").mockReturnValue(1000);
mount();
initSandboxRuntimeModular();
quiesce();
window.__player!.play();
frame120Hz();
const seeks = countSeeks();

frame120Hz();
const perFrame = seeks.length;
hostTick();

expect(perFrame).toBeGreaterThan(0);
expect(seeks.length).toBe(perFrame);
});

it("renders a newer host time after its own frame rendered an earlier time", () => {
let nowMs = 1000;
vi.spyOn(performance, "now").mockImplementation(() => nowMs);
mount();
initSandboxRuntimeModular();
quiesce();
window.__player!.play();
nowMs = 1008;
frame120Hz();
const seeks = countSeeks();

nowMs = 1024;
hostTick();

expect(seeks).toContain(0.024);
});

it("reapplies an explicit seek even when that timeline time was already rendered", () => {
vi.spyOn(performance, "now").mockReturnValue(1000);
mount();
initSandboxRuntimeModular();
quiesce();
window.__player!.play();
frame120Hz();
const timeline = window.__timelines!["main"]!;
timeline.totalTime!(1);
const seeks = countSeeks();

window.__player!.seek(0);

expect(seeks).toContain(0);
expect(timeline.totalTime!()).toBe(0);
});

it("refreshes the WebAudio snapshot on host ticks without iframe frames", () => {
let audioTime = 0;
mount();
initSandboxRuntimeModular();
quiesce();
window.__player!.play();
vi.spyOn(WebAudioTransport.prototype, "ownsClock").mockReturnValue(true);
vi.spyOn(WebAudioTransport.prototype, "context", "get").mockReturnValue({} as AudioContext);
vi.spyOn(WebAudioTransport.prototype, "getTime").mockImplementation(() => audioTime);
frame120Hz();
const seeks = countSeeks();

audioTime = 0.024;
hostTick();
const firstSeekCount = seeks.length;
hostTick();
expect(seeks.length).toBe(firstSeekCount);
audioTime = 0.048;
hostTick();

expect(seeks).toContain(0.024);
expect(seeks.at(-1)).toBe(0.048);
expect(window.__player!.getTime()).toBe(0.048);
});

it.each([false, true])(
"renders the first host tick after play with prior playback %s",
(resume) => {
let nowMs = 1000;
vi.spyOn(performance, "now").mockImplementation(() => nowMs);
mount();
initSandboxRuntimeModular();
quiesce();
if (resume) {
window.__player!.play();
frame120Hz();
window.__player!.pause();
quiesce();
}
window.__player!.play();
const seeks = countSeeks();
nowMs += 8;
vi.advanceTimersByTime(8);
hostTick();
expect(seeks.length).toBeGreaterThan(0);
},
);

it("advances on the host's ticks while its own animation frames are throttled", () => {
let nowMs = 1000;
vi.spyOn(performance, "now").mockImplementation(() => nowMs);
mount();
initSandboxRuntimeModular();
quiesce();
window.__player!.play();
frame120Hz();
hostTick();
const seeks = countSeeks();

for (let i = 0; i < 3; i += 1) {
nowMs += 8;
vi.advanceTimersByTime(8);
hostTick();
}

expect(seeks).toContain(0.008);
expect(seeks).toContain(0.016);
expect(seeks.at(-1)).toBe(0.024);
});

it("posts the paused bridge heartbeat on its documented interval while parked", () => {
mount();
initSandboxRuntimeModular();
Expand Down
4 changes: 2 additions & 2 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1192,8 +1192,8 @@ class HyperframesPlayer extends HTMLElement {
* iframe on every frame. Used for the runtime bridge path so that animation
* advances even when the composition iframe's own rAF is throttled by
* Chromium (e.g. deeply nested cross-origin iframes in Electron / Claude desktop).
* The runtime's own rAF loop still runs — ticking GSAP twice per frame is
* harmless because seekTimelineAndAdapters is idempotent.
* The runtime skips a tick seek only when that timeline time is already
* rendered. Host ticks still advance a throttled iframe.
*/
private _startParentTickClock(): void {
this._stopParentTickClock();
Expand Down
Loading