From aed26aec1cf5322d1b7962d2a6a485396a36f6d1 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 25 Aug 2026 16:24:13 -0700 Subject: [PATCH 1/7] fix(player): rebind replaced direct timelines --- .../player/src/hyperframes-player.test.ts | 30 +++++++++++++++++++ packages/player/src/hyperframes-player.ts | 10 ++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 5bc42fd38a..7143f1b470 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -983,6 +983,7 @@ describe("HyperframesPlayer seek() sync path", () => { stopMedia: () => void; iframe: HTMLIFrameElement; _currentTime: number; + duration: number; _parentMedia: Array<{ el: { pause: ReturnType; src: string }; start: number; @@ -1105,6 +1106,35 @@ describe("HyperframesPlayer seek() sync path", () => { expect(post).not.toHaveBeenCalled(); }); + it("rebinds when a same-origin composition replaces its registered timeline", () => { + const first: TimelineStub = { + duration: vi.fn(() => 5), + time: vi.fn(() => 0), + seek: vi.fn(), + play: vi.fn(), + pause: vi.fn(), + }; + const second: TimelineStub = { + duration: vi.fn(() => 8), + time: vi.fn(() => 0), + seek: vi.fn(), + play: vi.fn(), + pause: vi.fn(), + }; + const timelines = { main: first }; + const post = vi.fn(); + stubContentWindow({ __timelines: timelines, postMessage: post }); + + player.seek(1); + timelines.main = second; + player.seek(6); + + expect(first.seek).toHaveBeenCalledTimes(1); + expect(second.seek).toHaveBeenCalledWith(6, false); + expect(player.duration).toBe(8); + expect(post).not.toHaveBeenCalled(); + }); + it("plays and pauses same-origin __timelines when no runtime bridge exists", () => { const timeline: TimelineStub = { duration: vi.fn(() => 5), diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index badaa9e942..7aba19340c 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -798,10 +798,18 @@ class HyperframesPlayer extends HTMLElement { } private _withDirectTimeline(fn: (tl: DirectTimelineAdapter) => void): boolean { - const tl = this._directTimelineAdapter || this.probe.resolveDirectTimelineAdapter(); + const resolved = this.probe.resolveDirectTimelineAdapter(); + const tl = resolved || this._directTimelineAdapter; if (!tl) return false; try { fn(tl); + if (resolved && resolved !== this._directTimelineAdapter) { + const duration = resolved.duration(); + if (Number.isFinite(duration) && duration > 0) { + this._duration = duration; + this.controlsApi?.updateTime(this._currentTime, duration); + } + } this._directTimelineAdapter = tl; return true; } catch { From dcf0e2c5e3e47fb2a42fc9efeca8582e79e7dc06 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 25 Aug 2026 17:25:43 -0700 Subject: [PATCH 2/7] fix(runtime): rebind timelines after runtime data --- packages/core/src/runtime/init.test.ts | 112 +++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 43 +++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index e7328a4175..e7f52ea780 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -5,6 +5,11 @@ import { initSandboxRuntimeModular } from "./init"; import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu"; import { WebAudioTransport } from "./webAudioTransport"; import type { RuntimeTimelineLike } from "./types"; +import { + registerRuntimeDataHandler, + resetRuntimeDataForTests, + setRuntimeData, +} from "./runtimeData"; it("schedules WebAudio element gain from author volume without bridge volume", () => { const source = readFileSync("src/runtime/init.ts", "utf8"); @@ -107,6 +112,7 @@ describe("initSandboxRuntimeModular", () => { const originalCancelAnimationFrame = window.cancelAnimationFrame; beforeEach(() => { + resetRuntimeDataForTests(); document.body.innerHTML = ""; (globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {}; globalThis.CSS.escape ??= (value: string) => value; @@ -174,6 +180,7 @@ describe("initSandboxRuntimeModular", () => { afterEach(() => { window.__hfRuntimeTeardown?.(); + resetRuntimeDataForTests(); document.body.innerHTML = ""; window.__timelines = {} as Record; delete window.__player; @@ -2688,6 +2695,111 @@ describe("initSandboxRuntimeModular", () => { expect(clipControl?.style.visibility).toBe("visible"); }); + it("rebinds the injected player before reporting runtime-data applied", async () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const first = createMockTimeline(10); + const replacement = createMockTimeline(10); + window.__timelines = { main: first }; + const applied: Array> = []; + vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => { + if (typeof message !== "object" || message === null) return; + const payload = message as Record; + if (payload.type === "runtime-data-applied") applied.push(payload); + }); + + initSandboxRuntimeModular(); + window.__player?.seek(0.25); + registerRuntimeDataHandler("captions", async () => { + await Promise.resolve(); + window.__timelines = { main: replacement }; + }); + + setRuntimeData("captions", { style: "replacement" }, 7); + await vi.waitFor(() => expect(applied).toHaveLength(1)); + + // Runtime seeks are canonicalized to the configured frame rate. + expect(replacement.time()).toBeCloseTo(7 / 30, 5); + expect(first.time()).toBeCloseTo(7 / 30, 5); + + window.__player?.seek(1.25); + + expect(first.time()).toBeCloseTo(7 / 30, 5); + expect(replacement.time()).toBeCloseTo(37 / 30, 5); + expect(applied[0]).toMatchObject({ channel: "captions", requestId: 7 }); + }); + + it("does not seek a removed timeline after runtime data is cleared", async () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const first = createMockTimeline(10); + window.__timelines = { main: first }; + const applied: Array> = []; + vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => { + if (typeof message !== "object" || message === null) return; + const payload = message as Record; + if (payload.type === "runtime-data-applied") applied.push(payload); + }); + + initSandboxRuntimeModular(); + window.__player?.seek(0.25); + registerRuntimeDataHandler("captions", () => { + window.__timelines = {}; + }); + + setRuntimeData("captions", undefined, 8); + await vi.waitFor(() => expect(applied).toHaveLength(1)); + const timeAtClear = first.time(); + + window.__player?.seek(1.25); + + expect(first.time()).toBe(timeAtClear); + }); + + it("does not report applied when a runtime-data handler rejects", async () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + window.__timelines = { main: createMockTimeline(10) }; + + const applied: Array> = []; + const errors: Array> = []; + vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => { + if (typeof message !== "object" || message === null) return; + const payload = message as Record; + if (payload.type === "runtime-data-applied") applied.push(payload); + if (payload.type === "runtime-data-error") errors.push(payload); + }); + + initSandboxRuntimeModular(); + registerRuntimeDataHandler("captions", async () => { + await Promise.resolve(); + throw new Error("attach failed"); + }); + + setRuntimeData("captions", { style: "broken" }, 9); + await vi.waitFor(() => expect(errors).toHaveLength(1)); + + expect(applied).toHaveLength(0); + expect(errors[0]).toMatchObject({ channel: "captions", requestId: 9 }); + }); + it("onSetMuted preserves authored muted attribute on video elements", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "root"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c74488a402..b832b06ea7 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -135,6 +135,11 @@ function resolveExportRenderFps(): ExportRenderFpsResolution { export function initSandboxRuntimeModular(): void { const state = createRuntimeState(); + // Runtime-data handlers may replace the timeline object they mutate. Keep the + // reconciliation callback late-bound because the reporter is installed before + // the timeline resolver/binder is declared below. Delivery cannot complete + // until after init has installed the final callback. + let reconcileTimelineAfterRuntimeData: () => void = () => undefined; // Own the analytics bridge before any best-effort runtime installation so // early failures are observable instead of disappearing before player setup. initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void); @@ -148,6 +153,18 @@ export function initSandboxRuntimeModular(): void { }); }); setRuntimeDataAppliedReporter((channel, requestId) => { + try { + reconcileTimelineAfterRuntimeData(); + } catch (error) { + postRuntimeMessage({ + source: "hf-preview", + type: "runtime-data-error", + channel, + requestId, + message: error instanceof Error ? error.message : String(error), + }); + return; + } postRuntimeMessage({ source: "hf-preview", type: "runtime-data-applied", @@ -1549,11 +1566,30 @@ export function initSandboxRuntimeModular(): void { return true; }; - (window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = () => { - childrenBound = false; - bindRootTimelineIfAvailable(); + const reconcileTimeline = () => { + if (state.tornDown) return; + const resolution = resolveRootTimelineFromDocument(); + if (!resolution.timeline) { + // A successful clear must not leave the player seeking a killed timeline. + state.capturedTimeline = null; + childrenBound = false; + clock.setDuration(0); + syncTimedElementVisibility(state.currentTime); + return; + } + + // Avoid needlessly invalidating the child-binding cache when a handler + // updates data in place. A replacement object is the signal that a rebind + // is required. + if (state.capturedTimeline !== resolution.timeline) { + childrenBound = false; + bindRootTimelineIfAvailable(); + } syncTimedElementVisibility(state.currentTime); }; + reconcileTimelineAfterRuntimeData = reconcileTimeline; + (window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = + reconcileTimeline; const emitRootStageLayoutDiagnostics = () => { const rootNode = resolveRootCompositionElement(); @@ -3492,6 +3528,7 @@ export function initSandboxRuntimeModular(): void { } state.injectedCompScripts = []; state.capturedTimeline = null; + reconcileTimelineAfterRuntimeData = () => undefined; if (window.__hfRuntimeTeardown === teardown) { window.__hfRuntimeTeardown = null; } From 7d891071ea5db6e155f2083c0dd4c26953896d1f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 25 Aug 2026 20:06:40 -0700 Subject: [PATCH 3/7] fix(player): defer initial iframe navigation --- .../player/src/hyperframes-player.test.ts | 32 +++++++++++++++++++ packages/player/src/hyperframes-player.ts | 6 ++++ 2 files changed, 38 insertions(+) diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 7143f1b470..43063fd60d 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -525,6 +525,7 @@ describe("HyperframesPlayer shader transition options", () => { const player = document.createElement("hyperframes-player") as PlayerWithIframe; player.setAttribute("shader-capture-scale", "0.5"); player.setAttribute("shader-loading", "player"); + document.body.appendChild(player); player.setAttribute("src", "/api/projects/demo/preview?x=1#stage"); const url = new URL(player.iframeElement.src); @@ -539,6 +540,7 @@ describe("HyperframesPlayer shader transition options", () => { const player = document.createElement("hyperframes-player") as PlayerWithIframe; player.setAttribute("shader-capture-scale", "0.5"); player.setAttribute("shader-loading", "player"); + document.body.appendChild(player); player.setAttribute( "srcdoc", '', @@ -1512,6 +1514,36 @@ describe("HyperframesPlayer srcdoc attribute", () => { player.remove(); }); + it("does not navigate initial srcdoc before the runtime listener is connected", () => { + // React assigns custom-element attributes before inserting the element. If the observed + // attribute callback navigates the child iframe immediately, a fast srcdoc runtime can post + // its one-shot `ready` message before connectedCallback subscribes to `window.message`. + // Retained runtime data then waits forever and a caption style appears stuck on its bootstrap + // frame. The connect path owns the first navigation; attributeChangedCallback owns only + // subsequent swaps. + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("srcdoc", "deferred"); + + expect(player.iframe.hasAttribute("srcdoc")).toBe(false); + + document.body.appendChild(player); + expect(player.iframe.getAttribute("srcdoc")).toContain("deferred"); + + player.remove(); + }); + + it("does not navigate initial src before the runtime listener is connected", () => { + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("src", "/api/projects/deferred/preview"); + + expect(player.iframe.hasAttribute("src")).toBe(false); + + document.body.appendChild(player); + expect(player.iframe.getAttribute("src")).toBe("/api/projects/deferred/preview"); + + player.remove(); + }); + it("forwards a srcdoc attribute set after connect to the iframe", () => { // The composition-switching flow: same player element, new HTML. // Without `attributeChangedCallback` wiring this would no-op. diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 7aba19340c..d7067c1259 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -218,6 +218,11 @@ class HyperframesPlayer extends HTMLElement { attributeChangedCallback(name: string, oldVal: string | null, val: string | null) { switch (name) { case "src": + // Custom-element attributes are normally assigned before insertion (React does this for + // every render). Navigating the inner iframe here would let its one-shot runtime `ready` + // message fire before connectedCallback installs the parent message listener. Initial + // attributes are applied below by connectedCallback; only live changes navigate here. + if (!this.isConnected) break; if (val) { this._ready = false; this._runtimeBridgeReady = false; @@ -228,6 +233,7 @@ class HyperframesPlayer extends HTMLElement { } break; case "srcdoc": + if (!this.isConnected) break; this._ready = false; this._runtimeBridgeReady = false; this._rejectAllRuntimeDataDeliveries( From b7e64d23bee507286d1b3de33ac294d58e058671 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 25 Aug 2026 20:14:36 -0700 Subject: [PATCH 4/7] fix(player): preserve runtime readiness through load --- .../player/src/hyperframes-player.test.ts | 38 +++++++++++++++++++ packages/player/src/hyperframes-player.ts | 8 +++- packages/player/src/shader-options.ts | 14 ++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 43063fd60d..4a7942d513 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1494,6 +1494,33 @@ describe("HyperframesPlayer srcdoc attribute", () => { | undefined; expect(ctor).toBeDefined(); expect(ctor!.observedAttributes).toContain("srcdoc"); + expect(ctor!.observedAttributes).toContain("runtime-src"); + }); + + it("uses a configured runtime source for loopback srcdoc", () => { + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("runtime-src", "http://127.0.0.1:8900/hyperframe.runtime.iife.js"); + player.setAttribute("srcdoc", ""); + document.body.appendChild(player); + + expect(player.iframe.getAttribute("srcdoc")).toContain( + '', + ); + + player.remove(); + }); + + it("falls back to the pinned runtime for an unsafe runtime source", () => { + const player = document.createElement("hyperframes-player") as PlayerInternal; + player.setAttribute("runtime-src", 'javascript:alert("no")'); + player.setAttribute("srcdoc", ""); + document.body.appendChild(player); + + const srcdoc = player.iframe.getAttribute("srcdoc") ?? ""; + expect(srcdoc).not.toContain("javascript:"); + expect(srcdoc).toContain("hyperframe.runtime.iife.js"); + + player.remove(); }); it("forwards an initial srcdoc attribute to the iframe on connect", () => { @@ -1961,6 +1988,8 @@ describe("HyperframesPlayer runtime ready handshake", () => { paused: boolean; iframe: HTMLIFrameElement; _onMessage: (event: MessageEvent) => void; + _onIframeLoad: () => void; + _runtimeBridgeReady: boolean; } let player: PlayerInternal; @@ -2102,6 +2131,15 @@ describe("HyperframesPlayer runtime ready handshake", () => { expect(findControlCalls("set-muted")).toHaveLength(2); }); + it("does not erase a DOMContentLoaded runtime handshake when iframe load follows it", () => { + player._onMessage(readyMessage()); + expect(player._runtimeBridgeReady).toBe(true); + + player._onIframeLoad(); + + expect(player._runtimeBridgeReady).toBe(true); + }); + it("ignores ready events from a different window", () => { postSpy.mockClear(); const otherSource = {} as Window; diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index d7067c1259..7ea2765cbb 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -8,6 +8,7 @@ import { handleRuntimeMessage } from "./runtime-message-handler.js"; import { SHADER_CAPTURE_SCALE_ATTR, SHADER_LOADING_ATTR, + RUNTIME_SRC_ATTR, type ShaderLoadingMode, getShaderCaptureScaleFromElement, getShaderModeFromElement, @@ -78,6 +79,7 @@ class HyperframesPlayer extends HTMLElement { "playback-rate", "audio-src", SANDBOX_ORIGIN_ATTR, + RUNTIME_SRC_ATTR, SHADER_CAPTURE_SCALE_ATTR, SHADER_LOADING_ATTR, ]; @@ -297,6 +299,7 @@ class HyperframesPlayer extends HTMLElement { break; case SHADER_CAPTURE_SCALE_ATTR: case SHADER_LOADING_ATTR: + case RUNTIME_SRC_ATTR: this._reloadShaderOptions(); break; } @@ -987,7 +990,10 @@ class HyperframesPlayer extends HTMLElement { private _onIframeLoad() { this._ready = false; - this._runtimeBridgeReady = false; + // The runtime installs its bridge at DOMContentLoaded, posts `ready`, and only then does the + // iframe's load event fire. Do not erase that authoritative handshake here: doing so strands + // retained data set after load until a second `ready` that never comes. Source setters and + // sandbox-policy reloads already clear bridge readiness before starting a navigation. this._directTimelineAdapter = null; this._directTimelineClock.stop(); this._stopParentTickClock(); diff --git a/packages/player/src/shader-options.ts b/packages/player/src/shader-options.ts index 791f7a6da0..2eec42f796 100644 --- a/packages/player/src/shader-options.ts +++ b/packages/player/src/shader-options.ts @@ -9,6 +9,7 @@ import { RUNTIME_CDN_URL } from "./runtime-url.js"; export const SHADER_CAPTURE_SCALE_ATTR = "shader-capture-scale"; export const SHADER_LOADING_ATTR = "shader-loading"; +export const RUNTIME_SRC_ATTR = "runtime-src"; const SHADER_CAPTURE_SCALE_PARAM = "__hf_shader_capture_scale"; const SHADER_LOADING_PARAM = "__hf_shader_loading"; @@ -153,6 +154,17 @@ export function prepareSrcdocForElement(el: Element, srcdoc: string): string { normalizeShaderCaptureScale(el.getAttribute(SHADER_CAPTURE_SCALE_ATTR)), getShaderModeFromElement(el), ), - RUNTIME_CDN_URL, + runtimeSrcFromElement(el), ); } + +function runtimeSrcFromElement(el: Element): string { + const configured = el.getAttribute(RUNTIME_SRC_ATTR)?.trim(); + if (!configured) return RUNTIME_CDN_URL; + try { + const url = new URL(configured, document.baseURI); + return url.protocol === "http:" || url.protocol === "https:" ? url.href : RUNTIME_CDN_URL; + } catch { + return RUNTIME_CDN_URL; + } +} From dc12d6738bf01991d66caf4fed7029045ab0c20d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 25 Aug 2026 20:20:53 -0700 Subject: [PATCH 5/7] fix(runtime): publish rebound timeline before apply --- packages/core/src/runtime/init.test.ts | 6 ++++++ packages/core/src/runtime/init.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index e7f52ea780..60827d9f7d 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2708,13 +2708,18 @@ describe("initSandboxRuntimeModular", () => { const replacement = createMockTimeline(10); window.__timelines = { main: first }; const applied: Array> = []; + const deliveryOrder: string[] = []; vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => { if (typeof message !== "object" || message === null) return; const payload = message as Record; + if (payload.type === "timeline" || payload.type === "runtime-data-applied") { + deliveryOrder.push(String(payload.type)); + } if (payload.type === "runtime-data-applied") applied.push(payload); }); initSandboxRuntimeModular(); + deliveryOrder.length = 0; window.__player?.seek(0.25); registerRuntimeDataHandler("captions", async () => { await Promise.resolve(); @@ -2733,6 +2738,7 @@ describe("initSandboxRuntimeModular", () => { expect(first.time()).toBeCloseTo(7 / 30, 5); expect(replacement.time()).toBeCloseTo(37 / 30, 5); expect(applied[0]).toMatchObject({ channel: "captions", requestId: 7 }); + expect(deliveryOrder.slice(0, 2)).toEqual(["timeline", "runtime-data-applied"]); }); it("does not seek a removed timeline after runtime data is cleared", async () => { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index b832b06ea7..55b746ade3 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1587,7 +1587,14 @@ export function initSandboxRuntimeModular(): void { } syncTimedElementVisibility(state.currentTime); }; - reconcileTimelineAfterRuntimeData = reconcileTimeline; + reconcileTimelineAfterRuntimeData = () => { + reconcileTimeline(); + // The parent treats runtime-data-applied as permission to re-seek immediately. Publish the + // replacement duration first; otherwise that seek is clamped by the bootstrap timeline (often + // one second) and a style switch appears frozen on the first caption segment until some later + // polling tick happens to post the rebuilt timeline. + postTimeline(); + }; (window as Window & { __hfForceTimelineRebind?: () => void }).__hfForceTimelineRebind = reconcileTimeline; From 87a62ea413e61abe45239206cba2b9dfc096a2ca Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 25 Aug 2026 20:23:32 -0700 Subject: [PATCH 6/7] fix(player): defer preconnect option reloads --- packages/player/src/hyperframes-player.test.ts | 5 ++++- packages/player/src/hyperframes-player.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 4a7942d513..44368925d3 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1499,8 +1499,11 @@ describe("HyperframesPlayer srcdoc attribute", () => { it("uses a configured runtime source for loopback srcdoc", () => { const player = document.createElement("hyperframes-player") as PlayerInternal; - player.setAttribute("runtime-src", "http://127.0.0.1:8900/hyperframe.runtime.iife.js"); player.setAttribute("srcdoc", ""); + player.setAttribute("runtime-src", "http://127.0.0.1:8900/hyperframe.runtime.iife.js"); + + expect(player.iframe.hasAttribute("srcdoc")).toBe(false); + document.body.appendChild(player); expect(player.iframe.getAttribute("srcdoc")).toContain( diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 7ea2765cbb..84b31199f6 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -300,6 +300,7 @@ class HyperframesPlayer extends HTMLElement { case SHADER_CAPTURE_SCALE_ATTR: case SHADER_LOADING_ATTR: case RUNTIME_SRC_ATTR: + if (!this.isConnected) break; this._reloadShaderOptions(); break; } From c89f5d71cdf1c05689aa340834811bc599ce52cb Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 26 Aug 2026 11:58:50 -0700 Subject: [PATCH 7/7] fix(runtime): stop re-seeking paused timelines --- packages/core/src/runtime/init.test.ts | 65 +++++++++++++++++++++++++- packages/core/src/runtime/init.ts | 24 +++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 60827d9f7d..274b069e45 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2993,13 +2993,76 @@ describe("initSandboxRuntimeModular", () => { expect(seekTimes.length).toBeGreaterThan(beforePlaying); player?.pause(); - // (3) Paused + marker cleared (drop/cancel) → the per-frame re-seek resumes. + // (3) Paused + marker cleared (drop/cancel) → one reconciliation seek runs. document.getElementById("dragged")?.removeAttribute("data-hf-studio-manual-edit-gesture"); const beforeResume = seekTimes.length; raf.step(16); expect(seekTimes.length).toBeGreaterThan(beforeResume); }); + it("does not re-seek an unchanged paused timeline on every animation frame", () => { + const raf = createManualRaf(); + vi.spyOn(performance, "now").mockImplementation(() => raf.now()); + window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; + + const seekTimes: number[] = []; + const tl = createMockTimeline(5); + const origTotalTime = tl.totalTime; + tl.totalTime = ((time: number, ...rest: unknown[]) => { + seekTimes.push(time); + (origTotalTime as Function).call(tl, time, ...rest); + }) as RuntimeTimelineLike["totalTime"]; + + document.body.innerHTML = ` +
+ `; + window.__timelines = { root: tl }; + initSandboxRuntimeModular(); + + // The first transport frame reconciles the initial timeline at the paused playhead. + raf.step(16); + const afterInitialFrame = seekTimes.length; + expect(afterInitialFrame).toBeGreaterThan(0); + + // No time or timeline change means there is no new frame to render. + raf.step(16); + raf.step(16); + raf.step(16); + expect(seekTimes.length).toBe(afterInitialFrame); + + // An explicit paused seek still renders immediately, then settles again after the transport + // records the new playhead on its next frame. + window.__player?.seek(2); + expect(seekTimes.some((time) => time === 2)).toBe(true); + raf.step(16); + const afterPausedSeek = seekTimes.length; + raf.step(16); + expect(seekTimes.length).toBe(afterPausedSeek); + + // A runtime-data rebuild can replace the timeline without moving the paused playhead. The + // identity check must render that new object once instead of treating it as the old frame. + const replacementSeekTimes: number[] = []; + const replacement = createMockTimeline(5); + const replacementTotalTime = replacement.totalTime; + replacement.totalTime = ((time: number, ...rest: unknown[]) => { + replacementSeekTimes.push(time); + (replacementTotalTime as Function).call(replacement, time, ...rest); + }) as RuntimeTimelineLike["totalTime"]; + window.__timelines = { root: replacement }; + window.__hfForceTimelineRebind?.(); + raf.step(16); + expect(replacementSeekTimes.length).toBeGreaterThan(0); + const afterReplacementFrame = replacementSeekTimes.length; + raf.step(16); + expect(replacementSeekTimes.length).toBe(afterReplacementFrame); + + // Playback still traverses the timeline every frame. + window.__player?.play(); + raf.step(16); + expect(replacementSeekTimes.length).toBeGreaterThan(afterReplacementFrame); + }); + it("redraws animated grading from the transport clock only during playback", () => { const raf = createManualRaf(); vi.spyOn(performance, "now").mockImplementation(() => raf.now()); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 55b746ade3..0ac7c3ee7f 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2816,6 +2816,13 @@ 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. + let lastTransportSeekTime = Number.NaN; + let lastTransportSeekTimeline: RuntimeTimelineLike | null = null; + let pausedSeekDeferredByManualGesture = false; const seekRuntimeTimeline = ( timeline: RuntimeTimelineLike, @@ -3136,10 +3143,23 @@ export function initSandboxRuntimeModular(): void { // skipping the re-seek is a no-op for every other element; it resumes // the frame the gesture marker clears (drop/cancel). Playback is never // affected — the seek runs whenever the clock is playing. - if (clock.isPlaying() || !hasActiveStudioManualEditGesture()) { + const isPlaying = clock.isPlaying(); + const manualEditOwnsPausedFrame = !isPlaying && hasActiveStudioManualEditGesture(); + if (manualEditOwnsPausedFrame) { + // Force one reconciliation after drop/cancel even though the playhead did not move. + pausedSeekDeferredByManualGesture = true; + } else if ( + isPlaying || + pausedSeekDeferredByManualGesture || + t !== lastTransportSeekTime || + state.capturedTimeline !== lastTransportSeekTimeline + ) { seekTimelineAndAdapters(t); + lastTransportSeekTime = t; + lastTransportSeekTimeline = state.capturedTimeline; + if (!isPlaying) pausedSeekDeferredByManualGesture = false; } - if (clock.isPlaying()) { + if (isPlaying) { colorGrading.redrawAnimated(); }