Skip to content

Commit 587e392

Browse files
committed
fix(runtime): rebind timelines after runtime data
1 parent 61b3721 commit 587e392

2 files changed

Lines changed: 152 additions & 3 deletions

File tree

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

Lines changed: 112 additions & 0 deletions
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;
@@ -2688,6 +2695,111 @@ describe("initSandboxRuntimeModular", () => {
26882695
expect(clipControl?.style.visibility).toBe("visible");
26892696
});
26902697

2698+
it("rebinds the injected player before reporting runtime-data applied", async () => {
2699+
const root = document.createElement("div");
2700+
root.setAttribute("data-composition-id", "main");
2701+
root.setAttribute("data-root", "true");
2702+
root.setAttribute("data-duration", "10");
2703+
root.setAttribute("data-width", "1920");
2704+
root.setAttribute("data-height", "1080");
2705+
document.body.appendChild(root);
2706+
2707+
const first = createMockTimeline(10);
2708+
const replacement = createMockTimeline(10);
2709+
window.__timelines = { main: first };
2710+
const applied: Array<Record<string, unknown>> = [];
2711+
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
2712+
if (typeof message !== "object" || message === null) return;
2713+
const payload = message as Record<string, unknown>;
2714+
if (payload.type === "runtime-data-applied") applied.push(payload);
2715+
});
2716+
2717+
initSandboxRuntimeModular();
2718+
window.__player?.seek(0.25);
2719+
registerRuntimeDataHandler("captions", async () => {
2720+
await Promise.resolve();
2721+
window.__timelines = { main: replacement };
2722+
});
2723+
2724+
setRuntimeData("captions", { style: "replacement" }, 7);
2725+
await vi.waitFor(() => expect(applied).toHaveLength(1));
2726+
2727+
// Runtime seeks are canonicalized to the configured frame rate.
2728+
expect(replacement.time()).toBeCloseTo(7 / 30, 5);
2729+
expect(first.time()).toBeCloseTo(7 / 30, 5);
2730+
2731+
window.__player?.seek(1.25);
2732+
2733+
expect(first.time()).toBeCloseTo(7 / 30, 5);
2734+
expect(replacement.time()).toBeCloseTo(37 / 30, 5);
2735+
expect(applied[0]).toMatchObject({ channel: "captions", requestId: 7 });
2736+
});
2737+
2738+
it("does not seek a removed timeline after runtime data is cleared", async () => {
2739+
const root = document.createElement("div");
2740+
root.setAttribute("data-composition-id", "main");
2741+
root.setAttribute("data-root", "true");
2742+
root.setAttribute("data-duration", "10");
2743+
root.setAttribute("data-width", "1920");
2744+
root.setAttribute("data-height", "1080");
2745+
document.body.appendChild(root);
2746+
2747+
const first = createMockTimeline(10);
2748+
window.__timelines = { main: first };
2749+
const applied: Array<Record<string, unknown>> = [];
2750+
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
2751+
if (typeof message !== "object" || message === null) return;
2752+
const payload = message as Record<string, unknown>;
2753+
if (payload.type === "runtime-data-applied") applied.push(payload);
2754+
});
2755+
2756+
initSandboxRuntimeModular();
2757+
window.__player?.seek(0.25);
2758+
registerRuntimeDataHandler("captions", () => {
2759+
window.__timelines = {};
2760+
});
2761+
2762+
setRuntimeData("captions", undefined, 8);
2763+
await vi.waitFor(() => expect(applied).toHaveLength(1));
2764+
const timeAtClear = first.time();
2765+
2766+
window.__player?.seek(1.25);
2767+
2768+
expect(first.time()).toBe(timeAtClear);
2769+
});
2770+
2771+
it("does not report applied when a runtime-data handler rejects", async () => {
2772+
const root = document.createElement("div");
2773+
root.setAttribute("data-composition-id", "main");
2774+
root.setAttribute("data-root", "true");
2775+
root.setAttribute("data-duration", "10");
2776+
root.setAttribute("data-width", "1920");
2777+
root.setAttribute("data-height", "1080");
2778+
document.body.appendChild(root);
2779+
window.__timelines = { main: createMockTimeline(10) };
2780+
2781+
const applied: Array<Record<string, unknown>> = [];
2782+
const errors: Array<Record<string, unknown>> = [];
2783+
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
2784+
if (typeof message !== "object" || message === null) return;
2785+
const payload = message as Record<string, unknown>;
2786+
if (payload.type === "runtime-data-applied") applied.push(payload);
2787+
if (payload.type === "runtime-data-error") errors.push(payload);
2788+
});
2789+
2790+
initSandboxRuntimeModular();
2791+
registerRuntimeDataHandler("captions", async () => {
2792+
await Promise.resolve();
2793+
throw new Error("attach failed");
2794+
});
2795+
2796+
setRuntimeData("captions", { style: "broken" }, 9);
2797+
await vi.waitFor(() => expect(errors).toHaveLength(1));
2798+
2799+
expect(applied).toHaveLength(0);
2800+
expect(errors[0]).toMatchObject({ channel: "captions", requestId: 9 });
2801+
});
2802+
26912803
it("onSetMuted preserves authored muted attribute on video elements", () => {
26922804
const root = document.createElement("div");
26932805
root.setAttribute("data-composition-id", "root");

packages/core/src/runtime/init.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ function resolveExportRenderFps(): ExportRenderFpsResolution {
130130

131131
export function initSandboxRuntimeModular(): void {
132132
const state = createRuntimeState();
133+
// Runtime-data handlers may replace the timeline object they mutate. Keep the
134+
// reconciliation callback late-bound because the reporter is installed before
135+
// the timeline resolver/binder is declared below. Delivery cannot complete
136+
// until after init has installed the final callback.
137+
let reconcileTimelineAfterRuntimeData: () => void = () => undefined;
133138
// Own the analytics bridge before any best-effort runtime installation so
134139
// early failures are observable instead of disappearing before player setup.
135140
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
@@ -143,6 +148,18 @@ export function initSandboxRuntimeModular(): void {
143148
});
144149
});
145150
setRuntimeDataAppliedReporter((channel, requestId) => {
151+
try {
152+
reconcileTimelineAfterRuntimeData();
153+
} catch (error) {
154+
postRuntimeMessage({
155+
source: "hf-preview",
156+
type: "runtime-data-error",
157+
channel,
158+
requestId,
159+
message: error instanceof Error ? error.message : String(error),
160+
});
161+
return;
162+
}
146163
postRuntimeMessage({
147164
source: "hf-preview",
148165
type: "runtime-data-applied",
@@ -1544,11 +1561,30 @@ export function initSandboxRuntimeModular(): void {
15441561
return true;
15451562
};
15461563

1547-
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = () => {
1548-
childrenBound = false;
1549-
bindRootTimelineIfAvailable();
1564+
const reconcileTimeline = () => {
1565+
if (state.tornDown) return;
1566+
const resolution = resolveRootTimelineFromDocument();
1567+
if (!resolution.timeline) {
1568+
// A successful clear must not leave the player seeking a killed timeline.
1569+
state.capturedTimeline = null;
1570+
childrenBound = false;
1571+
clock.setDuration(0);
1572+
syncTimedElementVisibility(state.currentTime);
1573+
return;
1574+
}
1575+
1576+
// Avoid needlessly invalidating the child-binding cache when a handler
1577+
// updates data in place. A replacement object is the signal that a rebind
1578+
// is required.
1579+
if (state.capturedTimeline !== resolution.timeline) {
1580+
childrenBound = false;
1581+
bindRootTimelineIfAvailable();
1582+
}
15501583
syncTimedElementVisibility(state.currentTime);
15511584
};
1585+
reconcileTimelineAfterRuntimeData = reconcileTimeline;
1586+
(window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind =
1587+
reconcileTimeline;
15521588

15531589
const emitRootStageLayoutDiagnostics = () => {
15541590
const rootNode = resolveRootCompositionElement();
@@ -3427,6 +3463,7 @@ export function initSandboxRuntimeModular(): void {
34273463
}
34283464
state.injectedCompScripts = [];
34293465
state.capturedTimeline = null;
3466+
reconcileTimelineAfterRuntimeData = () => undefined;
34303467
if (window.__hfRuntimeTeardown === teardown) {
34313468
window.__hfRuntimeTeardown = null;
34323469
}

0 commit comments

Comments
 (0)