fix(studio): keep notion preview playback moving

This commit is contained in:
Miguel Ángel
2026-05-09 00:34:43 -07:00
parent 5ddf0285f5
commit de876a4fcc
8 changed files with 286 additions and 84 deletions
+91 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initSandboxRuntimeModular } from "./init";
import type { RuntimeTimelineLike } from "./types";
@@ -45,6 +45,31 @@ function createPaddableMockTimeline(duration: number): RuntimeTimelineLike {
return timeline;
}
function createManualRaf() {
let now = 0;
let nextId = 0;
const callbacks = new Map<number, FrameRequestCallback>();
return {
requestAnimationFrame: (callback: FrameRequestCallback) => {
nextId += 1;
callbacks.set(nextId, callback);
return nextId;
},
cancelAnimationFrame: (id: number) => {
callbacks.delete(id);
},
step: (milliseconds: number) => {
now += milliseconds;
const pending = Array.from(callbacks.entries());
callbacks.clear();
for (const [, callback] of pending) {
callback(now);
}
},
now: () => now,
};
}
describe("initSandboxRuntimeModular", () => {
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
@@ -67,6 +92,7 @@ describe("initSandboxRuntimeModular", () => {
delete (window as Window & { __player?: unknown }).__player;
delete (window as Window & { __playerReady?: boolean }).__playerReady;
delete (window as Window & { __renderReady?: boolean }).__renderReady;
vi.restoreAllMocks();
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
});
@@ -282,4 +308,68 @@ describe("initSandboxRuntimeModular", () => {
expect(video.paused).toBe(true);
expect(video.currentTime).toBe(0);
});
it("plays scheduled child timelines without a captured root timeline when audio has failed", () => {
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 root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "4");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
const child = document.createElement("div");
child.setAttribute("data-composition-id", "scene");
child.setAttribute("data-start", "0");
child.setAttribute("data-duration", "4");
root.appendChild(child);
const audio = document.createElement("audio");
audio.setAttribute("data-start", "0");
audio.setAttribute("data-duration", "4");
Object.defineProperty(audio, "error", {
value: { code: 4, message: "format error" },
configurable: true,
});
Object.defineProperty(audio, "networkState", {
value: HTMLMediaElement.NETWORK_NO_SOURCE,
configurable: true,
});
Object.defineProperty(audio, "readyState", {
value: HTMLMediaElement.HAVE_NOTHING,
configurable: true,
});
Object.defineProperty(audio, "paused", { value: true, configurable: true });
Object.defineProperty(audio, "currentTime", { value: 0, writable: true, configurable: true });
audio.load = () => {};
audio.play = vi.fn(() => Promise.reject(new Error("format error")));
root.appendChild(audio);
const childTimeline = createMockTimeline(4);
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
scene: childTimeline,
};
initSandboxRuntimeModular();
const player = (
window as Window & {
__player?: { play: () => void; getTime: () => number; isPlaying: () => boolean };
}
).__player;
expect(player).toBeDefined();
player?.play();
raf.step(1_000);
expect(player?.isPlaying()).toBe(true);
expect(player?.getTime()).toBeCloseTo(1, 1);
expect(childTimeline.time()).toBeCloseTo(1, 1);
});
});
+54 -12
View File
@@ -1450,6 +1450,7 @@ export function initSandboxRuntimeModular(): void {
.then(() => loadInlineTemplateCompositions(compositionLoaderParams))
.finally(() => {
externalCompositionsReady = true;
bindRootTimelineIfAvailable();
runAdapters("discover", state.currentTime);
bindMediaMetadataListeners();
installAssetFailureDiagnostics();
@@ -1644,18 +1645,53 @@ export function initSandboxRuntimeModular(): void {
let transportTickCount = 0;
let inTransportTick = false;
const seekRuntimeTimeline = (
timeline: RuntimeTimelineLike,
timeSeconds: number,
swallowLabel: string,
) => {
try {
timeline.pause();
if (typeof timeline.totalTime === "function") {
timeline.totalTime(timeSeconds, false);
} else {
timeline.seek(timeSeconds, false);
}
} catch (err) {
swallow(swallowLabel, err);
}
};
const seekStandaloneRegisteredTimelines = (timeSeconds: number) => {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
const rootCompositionId =
resolveRootCompositionElement()?.getAttribute("data-composition-id") ?? null;
for (const [compositionId, timeline] of Object.entries(timelines)) {
if (!timeline || compositionId === rootCompositionId) continue;
const node = document.querySelector(`[data-composition-id="${CSS.escape(compositionId)}"]`);
if (!node) continue;
const start = resolveStartForElement(node, 0);
if (!Number.isFinite(start)) continue;
const authoredDuration = resolveDurationForElement(node, {
includeAuthoredTimingAttrs: true,
});
const timelineDuration = getTimelineDurationSeconds(timeline);
const duration =
authoredDuration != null && authoredDuration > 0 ? authoredDuration : timelineDuration;
const localTime = Math.max(
0,
duration != null && duration > 0
? Math.min(duration, timeSeconds - start)
: timeSeconds - start,
);
seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline");
}
};
const seekTimelineAndAdapters = (t: number) => {
const tl = state.capturedTimeline;
if (tl) {
try {
if (typeof tl.totalTime === "function") {
tl.totalTime(t, false);
} else {
tl.seek(t, false);
}
} catch (err) {
swallow("runtime.init.transport.seek", err);
}
seekRuntimeTimeline(tl, t, "runtime.init.transport.seek");
// Sibling timelines (registered in __timelines but not nested under
// the root) are paused alongside the master. We do NOT seek them to
// absolute position `t` here — child timelines nested under the root
@@ -1663,6 +1699,8 @@ export function initSandboxRuntimeModular(): void {
// at absolute `t` would clobber their offset-relative position.
// Play/pause propagation for siblings happens in the player.play()
// and player.pause() overrides via the adapter layer.
} else {
seekStandaloneRegisteredTimelines(t);
}
for (const adapter of state.deterministicAdapters) {
try {
@@ -1741,7 +1779,11 @@ export function initSandboxRuntimeModular(): void {
if (!rawEl.paused) {
clock.attachAudioSource({ el: rawEl, compositionStart: start, mediaStart });
foundActive = true;
} else if (rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
} else if (
!rawEl.error &&
rawEl.networkState !== HTMLMediaElement.NETWORK_NO_SOURCE &&
rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA
) {
// Audio is buffering — freeze visuals at last known position
// instead of falling through to monotonic (which runs ahead).
clock.attachAudioSource({ currentTimeSeconds: state.currentTime });
@@ -1819,7 +1861,7 @@ export function initSandboxRuntimeModular(): void {
// Player methods route through the TransportClock.
player.play = () => {
const tl = state.capturedTimeline;
if (!tl || clock.isPlaying()) return;
if (clock.isPlaying()) return;
const dur = getSafeTimelineDurationSeconds(tl, 0);
if (dur > 0) {
clock.setDuration(dur);
@@ -1829,7 +1871,7 @@ export function initSandboxRuntimeModular(): void {
seekTimelineAndAdapters(0);
}
}
tl.pause();
if (tl) tl.pause();
if (!clock.play()) return;
state.isPlaying = true;
state.mediaForceSyncNextTick = true;
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { shouldShowCompositionLoadingOverlay } from "./Player";
import { hasUnloadedAssets, shouldShowCompositionLoadingOverlay } from "./Player";
describe("composition loading overlay", () => {
it("shows while the composition is loading", () => {
@@ -9,4 +11,48 @@ describe("composition loading overlay", () => {
it("hides after the composition is ready", () => {
expect(shouldShowCompositionLoadingOverlay(false)).toBe(false);
});
it("keeps the asset overlay up while media is still buffering", () => {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const audio = iframe.contentDocument?.createElement("audio");
expect(audio).toBeDefined();
Object.defineProperty(audio, "readyState", {
value: 0,
configurable: true,
});
Object.defineProperty(audio, "networkState", {
value: 2,
configurable: true,
});
iframe.contentDocument?.body.appendChild(audio!);
expect(hasUnloadedAssets(iframe, false)).toBe(true);
iframe.remove();
});
it("does not keep the asset overlay stuck on failed media sources", () => {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
const audio = iframe.contentDocument?.createElement("audio");
expect(audio).toBeDefined();
Object.defineProperty(audio, "error", {
value: { code: 4, message: "format error" },
configurable: true,
});
Object.defineProperty(audio, "readyState", {
value: 0,
configurable: true,
});
Object.defineProperty(audio, "networkState", {
value: 3,
configurable: true,
});
iframe.contentDocument?.body.appendChild(audio!);
expect(hasUnloadedAssets(iframe, false)).toBe(false);
iframe.remove();
});
});
@@ -19,6 +19,9 @@ interface HyperframesPlayerElement extends HTMLElement {
iframeElement: HTMLIFrameElement;
}
const MEDIA_HAVE_FUTURE_DATA = 3;
const MEDIA_NETWORK_NO_SOURCE = 3;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -47,6 +50,11 @@ function enableInteractiveIframe(player: HyperframesPlayerElement): void {
iframe?.style.setProperty("pointer-events", "auto");
}
function isPreviewMediaElement(el: Element): el is HTMLMediaElement {
const tagName = el.tagName.toLowerCase();
return tagName === "video" || tagName === "audio";
}
// Assets are considered ready when every `<video>`/`<audio>` has enough data
// to play through without buffering, and every registered Lottie animation has
// finished loading.
@@ -55,14 +63,19 @@ function enableInteractiveIframe(player: HyperframesPlayerElement): void {
// races so a brief access failure (e.g. an iframe that just swapped src)
// doesn't flicker the overlay state — we keep showing whatever was most
// recently true.
function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
try {
const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
const doc = iframe.contentDocument;
if (!win || !doc) return lastResult;
for (const el of doc.querySelectorAll("video, audio")) {
if (el instanceof HTMLMediaElement && el.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
if (
isPreviewMediaElement(el) &&
!el.error &&
el.networkState !== MEDIA_NETWORK_NO_SOURCE &&
el.readyState < MEDIA_HAVE_FUTURE_DATA
) {
return true;
}
}
+79 -68
View File
@@ -53,7 +53,7 @@ async function getSharedBrowser(): Promise<import("puppeteer-core").Browser | nu
}
// In-flight thumbnail dedup
const _thumbnailInflight = new Map<string, Promise<Buffer>>();
const _thumbnailInflight = new Map<string, Promise<Buffer | null>>();
const THUMBNAIL_CACHE_VERSION = "v4";
interface ScreenshotClip {
@@ -335,77 +335,88 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
const browser = await getSharedBrowser();
if (!browser) return null;
const page = await browser.newPage();
await page.setViewport({
width: opts.width,
height: opts.height,
deviceScaleFactor: opts.format === "png" ? 1 : 0.5,
});
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.evaluate(() => {
document.documentElement.style.background = "#000";
document.body.style.background = "#000";
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
});
await page
.waitForFunction(
`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`,
{ timeout: 5000 },
)
.catch(() => {});
await seekThumbnailPreview(page, opts.seekTime);
await applyStudioRenderBodyScriptsToThumbnailPage(page, opts.project.dir, opts.compPath);
await page.evaluate("document.fonts?.ready");
await new Promise((r) => setTimeout(r, 200));
await reapplyStudioRenderBodyScriptsToThumbnailPage(page);
let clip: ScreenshotClip | undefined;
if (opts.selector) {
clip = await page.evaluate(
(selector: string, selectorIndex: number | undefined) => {
const matches = Array.from(document.querySelectorAll(selector)).filter(
(el): el is HTMLElement => el instanceof HTMLElement,
);
const safeIndex = Math.max(
0,
Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)),
);
const el = matches[safeIndex] ?? null;
if (!(el instanceof HTMLElement)) return undefined;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
const pad = 8;
const x = Math.max(0, rect.left - pad);
const y = Math.max(0, rect.top - pad);
const maxWidth = window.innerWidth - x;
const maxHeight = window.innerHeight - y;
return {
x,
y,
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
};
},
opts.selector,
opts.selectorIndex,
try {
await page.setViewport({
width: opts.width,
height: opts.height,
deviceScaleFactor: opts.format === "png" ? 1 : 0.5,
});
await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 });
await page.evaluate(() => {
document.documentElement.style.background = "#000";
document.body.style.background = "#000";
document.body.style.margin = "0";
document.body.style.overflow = "hidden";
});
await page
.waitForFunction(
`!!(window.__timelines && Object.keys(window.__timelines).length > 0)`,
{ timeout: 5000 },
)
.catch(() => {});
await seekThumbnailPreview(page, opts.seekTime);
await applyStudioRenderBodyScriptsToThumbnailPage(
page,
opts.project.dir,
opts.compPath,
);
}
const buf = await page.screenshot(
opts.format === "png"
? {
type: "png",
...(clip ? { clip } : {}),
}
: {
type: "jpeg",
quality: 75,
...(clip ? { clip } : {}),
await page.evaluate("document.fonts?.ready");
await new Promise((r) => setTimeout(r, 200));
await reapplyStudioRenderBodyScriptsToThumbnailPage(page);
let clip: ScreenshotClip | undefined;
if (opts.selector) {
clip = await page.evaluate(
(selector: string, selectorIndex: number | undefined) => {
const matches = Array.from(document.querySelectorAll(selector)).filter(
(el): el is HTMLElement => el instanceof HTMLElement,
);
const safeIndex = Math.max(
0,
Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)),
);
const el = matches[safeIndex] ?? null;
if (!(el instanceof HTMLElement)) return undefined;
const rect = el.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) return undefined;
const pad = 8;
const x = Math.max(0, rect.left - pad);
const y = Math.max(0, rect.top - pad);
const maxWidth = window.innerWidth - x;
const maxHeight = window.innerHeight - y;
return {
x,
y,
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)),
};
},
);
await page.close();
return buf as Buffer;
opts.selector,
opts.selectorIndex,
);
}
const buf = await page.screenshot(
opts.format === "png"
? {
type: "png",
...(clip ? { clip } : {}),
}
: {
type: "jpeg",
quality: 75,
...(clip ? { clip } : {}),
},
);
return buf as Buffer;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[Studio thumbnail] Failed to generate ${opts.compPath}: ${message}`);
return null;
} finally {
await page.close().catch(() => {});
}
})();
_thumbnailInflight.set(cacheKey, bufferPromise);
bufferPromise.finally(() => _thumbnailInflight.delete(cacheKey));
void bufferPromise.finally(() => _thumbnailInflight.delete(cacheKey));
}
return bufferPromise;
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB