Skip to content

Commit 61ba800

Browse files
authored
fix(player/runtime): rebind timelines and bound paused seeks (#3489)
* fix(player): rebind replaced direct timelines * fix(runtime): rebind timelines after runtime data * fix(player): defer initial iframe navigation * fix(player): preserve runtime readiness through load * fix(runtime): publish rebound timeline before apply * fix(player): defer preconnect option reloads * fix(runtime): stop re-seeking paused timelines * fix(player): restrict runtime-src to trusted origins and reset readiness on reload
1 parent 859ac62 commit 61ba800

5 files changed

Lines changed: 431 additions & 9 deletions

File tree

packages/core/src/runtime/init.test.ts

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { initSandboxRuntimeModular } from "./init";
55
import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu";
66
import { WebAudioTransport } from "./webAudioTransport";
77
import type { RuntimeTimelineLike } from "./types";
8+
import {
9+
registerRuntimeDataHandler,
10+
resetRuntimeDataForTests,
11+
setRuntimeData,
12+
} from "./runtimeData";
813

914
it("schedules WebAudio element gain from author volume without bridge volume", () => {
1015
const source = readFileSync("src/runtime/init.ts", "utf8");
@@ -107,6 +112,7 @@ describe("initSandboxRuntimeModular", () => {
107112
const originalCancelAnimationFrame = window.cancelAnimationFrame;
108113

109114
beforeEach(() => {
115+
resetRuntimeDataForTests();
110116
document.body.innerHTML = "";
111117
(globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {};
112118
globalThis.CSS.escape ??= (value: string) => value;
@@ -174,6 +180,7 @@ describe("initSandboxRuntimeModular", () => {
174180

175181
afterEach(() => {
176182
window.__hfRuntimeTeardown?.();
183+
resetRuntimeDataForTests();
177184
document.body.innerHTML = "";
178185
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
179186
delete window.__player;
@@ -2725,6 +2732,117 @@ describe("initSandboxRuntimeModular", () => {
27252732
expect(clipControl?.style.visibility).toBe("visible");
27262733
});
27272734

2735+
it("rebinds the injected player before reporting runtime-data applied", async () => {
2736+
const root = document.createElement("div");
2737+
root.setAttribute("data-composition-id", "main");
2738+
root.setAttribute("data-root", "true");
2739+
root.setAttribute("data-duration", "10");
2740+
root.setAttribute("data-width", "1920");
2741+
root.setAttribute("data-height", "1080");
2742+
document.body.appendChild(root);
2743+
2744+
const first = createMockTimeline(10);
2745+
const replacement = createMockTimeline(10);
2746+
window.__timelines = { main: first };
2747+
const applied: Array<Record<string, unknown>> = [];
2748+
const deliveryOrder: string[] = [];
2749+
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
2750+
if (typeof message !== "object" || message === null) return;
2751+
const payload = message as Record<string, unknown>;
2752+
if (payload.type === "timeline" || payload.type === "runtime-data-applied") {
2753+
deliveryOrder.push(String(payload.type));
2754+
}
2755+
if (payload.type === "runtime-data-applied") applied.push(payload);
2756+
});
2757+
2758+
initSandboxRuntimeModular();
2759+
deliveryOrder.length = 0;
2760+
window.__player?.seek(0.25);
2761+
registerRuntimeDataHandler("captions", async () => {
2762+
await Promise.resolve();
2763+
window.__timelines = { main: replacement };
2764+
});
2765+
2766+
setRuntimeData("captions", { style: "replacement" }, 7);
2767+
await vi.waitFor(() => expect(applied).toHaveLength(1));
2768+
2769+
// Runtime seeks are canonicalized to the configured frame rate.
2770+
expect(replacement.time()).toBeCloseTo(7 / 30, 5);
2771+
expect(first.time()).toBeCloseTo(7 / 30, 5);
2772+
2773+
window.__player?.seek(1.25);
2774+
2775+
expect(first.time()).toBeCloseTo(7 / 30, 5);
2776+
expect(replacement.time()).toBeCloseTo(37 / 30, 5);
2777+
expect(applied[0]).toMatchObject({ channel: "captions", requestId: 7 });
2778+
expect(deliveryOrder.slice(0, 2)).toEqual(["timeline", "runtime-data-applied"]);
2779+
});
2780+
2781+
it("does not seek a removed timeline after runtime data is cleared", async () => {
2782+
const root = document.createElement("div");
2783+
root.setAttribute("data-composition-id", "main");
2784+
root.setAttribute("data-root", "true");
2785+
root.setAttribute("data-duration", "10");
2786+
root.setAttribute("data-width", "1920");
2787+
root.setAttribute("data-height", "1080");
2788+
document.body.appendChild(root);
2789+
2790+
const first = createMockTimeline(10);
2791+
window.__timelines = { main: first };
2792+
const applied: Array<Record<string, unknown>> = [];
2793+
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
2794+
if (typeof message !== "object" || message === null) return;
2795+
const payload = message as Record<string, unknown>;
2796+
if (payload.type === "runtime-data-applied") applied.push(payload);
2797+
});
2798+
2799+
initSandboxRuntimeModular();
2800+
window.__player?.seek(0.25);
2801+
registerRuntimeDataHandler("captions", () => {
2802+
window.__timelines = {};
2803+
});
2804+
2805+
setRuntimeData("captions", undefined, 8);
2806+
await vi.waitFor(() => expect(applied).toHaveLength(1));
2807+
const timeAtClear = first.time();
2808+
2809+
window.__player?.seek(1.25);
2810+
2811+
expect(first.time()).toBe(timeAtClear);
2812+
});
2813+
2814+
it("does not report applied when a runtime-data handler rejects", async () => {
2815+
const root = document.createElement("div");
2816+
root.setAttribute("data-composition-id", "main");
2817+
root.setAttribute("data-root", "true");
2818+
root.setAttribute("data-duration", "10");
2819+
root.setAttribute("data-width", "1920");
2820+
root.setAttribute("data-height", "1080");
2821+
document.body.appendChild(root);
2822+
window.__timelines = { main: createMockTimeline(10) };
2823+
2824+
const applied: Array<Record<string, unknown>> = [];
2825+
const errors: Array<Record<string, unknown>> = [];
2826+
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
2827+
if (typeof message !== "object" || message === null) return;
2828+
const payload = message as Record<string, unknown>;
2829+
if (payload.type === "runtime-data-applied") applied.push(payload);
2830+
if (payload.type === "runtime-data-error") errors.push(payload);
2831+
});
2832+
2833+
initSandboxRuntimeModular();
2834+
registerRuntimeDataHandler("captions", async () => {
2835+
await Promise.resolve();
2836+
throw new Error("attach failed");
2837+
});
2838+
2839+
setRuntimeData("captions", { style: "broken" }, 9);
2840+
await vi.waitFor(() => expect(errors).toHaveLength(1));
2841+
2842+
expect(applied).toHaveLength(0);
2843+
expect(errors[0]).toMatchObject({ channel: "captions", requestId: 9 });
2844+
});
2845+
27282846
it("onSetMuted preserves authored muted attribute on video elements", () => {
27292847
const root = document.createElement("div");
27302848
root.setAttribute("data-composition-id", "root");
@@ -2912,13 +3030,76 @@ describe("initSandboxRuntimeModular", () => {
29123030
expect(seekTimes.length).toBeGreaterThan(beforePlaying);
29133031
player?.pause();
29143032

2915-
// (3) Paused + marker cleared (drop/cancel) → the per-frame re-seek resumes.
3033+
// (3) Paused + marker cleared (drop/cancel) → one reconciliation seek runs.
29163034
document.getElementById("dragged")?.removeAttribute("data-hf-studio-manual-edit-gesture");
29173035
const beforeResume = seekTimes.length;
29183036
raf.step(16);
29193037
expect(seekTimes.length).toBeGreaterThan(beforeResume);
29203038
});
29213039

3040+
it("does not re-seek an unchanged paused timeline on every animation frame", () => {
3041+
const raf = createManualRaf();
3042+
vi.spyOn(performance, "now").mockImplementation(() => raf.now());
3043+
window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame;
3044+
window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame;
3045+
3046+
const seekTimes: number[] = [];
3047+
const tl = createMockTimeline(5);
3048+
const origTotalTime = tl.totalTime;
3049+
tl.totalTime = ((time: number, ...rest: unknown[]) => {
3050+
seekTimes.push(time);
3051+
(origTotalTime as Function).call(tl, time, ...rest);
3052+
}) as RuntimeTimelineLike["totalTime"];
3053+
3054+
document.body.innerHTML = `
3055+
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080"></div>
3056+
`;
3057+
window.__timelines = { root: tl };
3058+
initSandboxRuntimeModular();
3059+
3060+
// The first transport frame reconciles the initial timeline at the paused playhead.
3061+
raf.step(16);
3062+
const afterInitialFrame = seekTimes.length;
3063+
expect(afterInitialFrame).toBeGreaterThan(0);
3064+
3065+
// No time or timeline change means there is no new frame to render.
3066+
raf.step(16);
3067+
raf.step(16);
3068+
raf.step(16);
3069+
expect(seekTimes.length).toBe(afterInitialFrame);
3070+
3071+
// An explicit paused seek still renders immediately, then settles again after the transport
3072+
// records the new playhead on its next frame.
3073+
window.__player?.seek(2);
3074+
expect(seekTimes.some((time) => time === 2)).toBe(true);
3075+
raf.step(16);
3076+
const afterPausedSeek = seekTimes.length;
3077+
raf.step(16);
3078+
expect(seekTimes.length).toBe(afterPausedSeek);
3079+
3080+
// A runtime-data rebuild can replace the timeline without moving the paused playhead. The
3081+
// identity check must render that new object once instead of treating it as the old frame.
3082+
const replacementSeekTimes: number[] = [];
3083+
const replacement = createMockTimeline(5);
3084+
const replacementTotalTime = replacement.totalTime;
3085+
replacement.totalTime = ((time: number, ...rest: unknown[]) => {
3086+
replacementSeekTimes.push(time);
3087+
(replacementTotalTime as Function).call(replacement, time, ...rest);
3088+
}) as RuntimeTimelineLike["totalTime"];
3089+
window.__timelines = { root: replacement };
3090+
window.__hfForceTimelineRebind?.();
3091+
raf.step(16);
3092+
expect(replacementSeekTimes.length).toBeGreaterThan(0);
3093+
const afterReplacementFrame = replacementSeekTimes.length;
3094+
raf.step(16);
3095+
expect(replacementSeekTimes.length).toBe(afterReplacementFrame);
3096+
3097+
// Playback still traverses the timeline every frame.
3098+
window.__player?.play();
3099+
raf.step(16);
3100+
expect(replacementSeekTimes.length).toBeGreaterThan(afterReplacementFrame);
3101+
});
3102+
29223103
it("redraws animated grading from the transport clock only during playback", () => {
29233104
const raf = createManualRaf();
29243105
vi.spyOn(performance, "now").mockImplementation(() => raf.now());

packages/core/src/runtime/init.ts

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ function resolveExportRenderFps(): ExportRenderFpsResolution {
135135

136136
export function initSandboxRuntimeModular(): void {
137137
const state = createRuntimeState();
138+
// Runtime-data handlers may replace the timeline object they mutate. Keep the
139+
// reconciliation callback late-bound because the reporter is installed before
140+
// the timeline resolver/binder is declared below. Delivery cannot complete
141+
// until after init has installed the final callback.
142+
let reconcileTimelineAfterRuntimeData: () => void = () => undefined;
138143
// Own the analytics bridge before any best-effort runtime installation so
139144
// early failures are observable instead of disappearing before player setup.
140145
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
@@ -148,6 +153,18 @@ export function initSandboxRuntimeModular(): void {
148153
});
149154
});
150155
setRuntimeDataAppliedReporter((channel, requestId) => {
156+
try {
157+
reconcileTimelineAfterRuntimeData();
158+
} catch (error) {
159+
postRuntimeMessage({
160+
source: "hf-preview",
161+
type: "runtime-data-error",
162+
channel,
163+
requestId,
164+
message: error instanceof Error ? error.message : String(error),
165+
});
166+
return;
167+
}
151168
postRuntimeMessage({
152169
source: "hf-preview",
153170
type: "runtime-data-applied",
@@ -1542,11 +1559,37 @@ export function initSandboxRuntimeModular(): void {
15421559
return true;
15431560
};
15441561

1545-
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = () => {
1546-
childrenBound = false;
1547-
bindRootTimelineIfAvailable();
1562+
const reconcileTimeline = () => {
1563+
if (state.tornDown) return;
1564+
const resolution = resolveRootTimelineFromDocument();
1565+
if (!resolution.timeline) {
1566+
// A successful clear must not leave the player seeking a killed timeline.
1567+
state.capturedTimeline = null;
1568+
childrenBound = false;
1569+
clock.setDuration(0);
1570+
syncTimedElementVisibility(state.currentTime);
1571+
return;
1572+
}
1573+
1574+
// Avoid needlessly invalidating the child-binding cache when a handler
1575+
// updates data in place. A replacement object is the signal that a rebind
1576+
// is required.
1577+
if (state.capturedTimeline !== resolution.timeline) {
1578+
childrenBound = false;
1579+
bindRootTimelineIfAvailable();
1580+
}
15481581
syncTimedElementVisibility(state.currentTime);
15491582
};
1583+
reconcileTimelineAfterRuntimeData = () => {
1584+
reconcileTimeline();
1585+
// The parent treats runtime-data-applied as permission to re-seek immediately. Publish the
1586+
// replacement duration first; otherwise that seek is clamped by the bootstrap timeline (often
1587+
// one second) and a style switch appears frozen on the first caption segment until some later
1588+
// polling tick happens to post the rebuilt timeline.
1589+
postTimeline();
1590+
};
1591+
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind =
1592+
reconcileTimeline;
15501593

15511594
const emitRootStageLayoutDiagnostics = () => {
15521595
const rootNode = resolveRootCompositionElement();
@@ -2766,6 +2809,13 @@ export function initSandboxRuntimeModular(): void {
27662809
}
27672810
let transportTickCount = 0;
27682811
let inTransportTick = false;
2812+
// A paused transport has no new frame to render. Re-seeking the same GSAP timeline at the
2813+
// same time on every rAF is not merely redundant: one picker can embed several paused
2814+
// players, multiplying full timeline traversal and style invalidation across every iframe.
2815+
// Keep enough identity to render once when time or the asynchronously-bound timeline changes.
2816+
let lastTransportSeekTime = Number.NaN;
2817+
let lastTransportSeekTimeline: RuntimeTimelineLike | null = null;
2818+
let pausedSeekDeferredByManualGesture = false;
27692819

27702820
const seekRuntimeTimeline = (
27712821
timeline: RuntimeTimelineLike,
@@ -3086,10 +3136,23 @@ export function initSandboxRuntimeModular(): void {
30863136
// skipping the re-seek is a no-op for every other element; it resumes
30873137
// the frame the gesture marker clears (drop/cancel). Playback is never
30883138
// affected — the seek runs whenever the clock is playing.
3089-
if (clock.isPlaying() || !hasActiveStudioManualEditGesture()) {
3139+
const isPlaying = clock.isPlaying();
3140+
const manualEditOwnsPausedFrame = !isPlaying && hasActiveStudioManualEditGesture();
3141+
if (manualEditOwnsPausedFrame) {
3142+
// Force one reconciliation after drop/cancel even though the playhead did not move.
3143+
pausedSeekDeferredByManualGesture = true;
3144+
} else if (
3145+
isPlaying ||
3146+
pausedSeekDeferredByManualGesture ||
3147+
t !== lastTransportSeekTime ||
3148+
state.capturedTimeline !== lastTransportSeekTimeline
3149+
) {
30903150
seekTimelineAndAdapters(t);
3151+
lastTransportSeekTime = t;
3152+
lastTransportSeekTimeline = state.capturedTimeline;
3153+
if (!isPlaying) pausedSeekDeferredByManualGesture = false;
30913154
}
3092-
if (clock.isPlaying()) {
3155+
if (isPlaying) {
30933156
colorGrading.redrawAnimated();
30943157
}
30953158

@@ -3485,6 +3548,7 @@ export function initSandboxRuntimeModular(): void {
34853548
}
34863549
state.injectedCompScripts = [];
34873550
state.capturedTimeline = null;
3551+
reconcileTimelineAfterRuntimeData = () => undefined;
34883552
if (window.__hfRuntimeTeardown === teardown) {
34893553
window.__hfRuntimeTeardown = null;
34903554
}

0 commit comments

Comments
 (0)