Skip to content
Open
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
183 changes: 182 additions & 1 deletion packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -174,6 +180,7 @@ describe("initSandboxRuntimeModular", () => {

afterEach(() => {
window.__hfRuntimeTeardown?.();
resetRuntimeDataForTests();
document.body.innerHTML = "";
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
delete window.__player;
Expand Down Expand Up @@ -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<Record<string, unknown>> = [];
const deliveryOrder: string[] = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
if (typeof message !== "object" || message === null) return;
const payload = message as Record<string, unknown>;
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<Record<string, unknown>> = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
if (typeof message !== "object" || message === null) return;
const payload = message as Record<string, unknown>;
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<Record<string, unknown>> = [];
const errors: Array<Record<string, unknown>> = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
if (typeof message !== "object" || message === null) return;
const payload = message as Record<string, unknown>;
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");
Expand Down Expand Up @@ -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 = `
<div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080"></div>
`;
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());
Expand Down
74 changes: 69 additions & 5 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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",
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -3492,6 +3555,7 @@ export function initSandboxRuntimeModular(): void {
}
state.injectedCompScripts = [];
state.capturedTimeline = null;
reconcileTimelineAfterRuntimeData = () => undefined;
if (window.__hfRuntimeTeardown === teardown) {
window.__hfRuntimeTeardown = null;
}
Expand Down
Loading
Loading