mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 05:37:09 +00:00
fix(studio): keep notion preview playback moving
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user