diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index e7328a4175..274b069e45 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,117 @@ 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> = []; + 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(); + 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 }); + expect(deliveryOrder.slice(0, 2)).toEqual(["timeline", "runtime-data-applied"]); + }); + + 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"); @@ -2875,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 c74488a402..0ac7c3ee7f 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,37 @@ 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(); + // 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; const emitRootStageLayoutDiagnostics = () => { const rootNode = resolveRootCompositionElement(); @@ -2773,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, @@ -3093,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(); } @@ -3492,6 +3555,7 @@ export function initSandboxRuntimeModular(): void { } state.injectedCompScripts = []; state.capturedTimeline = null; + reconcileTimelineAfterRuntimeData = () => undefined; if (window.__hfRuntimeTeardown === teardown) { window.__hfRuntimeTeardown = null; } diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 5bc42fd38a..44368925d3 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", '', @@ -983,6 +985,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 +1108,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), @@ -1462,6 +1494,36 @@ 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("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( + '', + ); + + 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", () => { @@ -1482,6 +1544,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. @@ -1899,6 +1991,8 @@ describe("HyperframesPlayer runtime ready handshake", () => { paused: boolean; iframe: HTMLIFrameElement; _onMessage: (event: MessageEvent) => void; + _onIframeLoad: () => void; + _runtimeBridgeReady: boolean; } let player: PlayerInternal; @@ -2040,6 +2134,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 badaa9e942..84b31199f6 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, ]; @@ -218,6 +220,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 +235,7 @@ class HyperframesPlayer extends HTMLElement { } break; case "srcdoc": + if (!this.isConnected) break; this._ready = false; this._runtimeBridgeReady = false; this._rejectAllRuntimeDataDeliveries( @@ -291,6 +299,8 @@ class HyperframesPlayer extends HTMLElement { break; case SHADER_CAPTURE_SCALE_ATTR: case SHADER_LOADING_ATTR: + case RUNTIME_SRC_ATTR: + if (!this.isConnected) break; this._reloadShaderOptions(); break; } @@ -798,10 +808,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 { @@ -973,7 +991,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; + } +}