mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-03 20:37:30 +00:00
fix(studio): undo and redo are no longer held behind thumbnail renders (#4842)
* fix(studio): undo and redo are no longer held behind thumbnail renders * test(studio): composition card tests share one mount, and the bench's 2 s undo rule is tested * fix(studio): a composition card and its timeline clip share one thumbnail render * test(studio): bank opening a film at 15 thumbnail requests, one per composition * fix(studio): composition cards load near the viewport and keep their last frame until the next * fix(studio): a composition card keeps its last frame under cache pressure, and loads 200 px early
This commit is contained in:
@@ -413,13 +413,14 @@ describe("NLEPreview", () => {
|
||||
describe("a missing poster", () => {
|
||||
const renderUrl =
|
||||
"/api/projects/timeline-edit-playground/thumbnail/index.html?t=0&output=source";
|
||||
const fetchSpy = vi.fn(() => Promise.resolve(new Response()));
|
||||
const fetchSpy = vi.fn((_url: string, _init?: RequestInit) => Promise.resolve(new Response()));
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockClear();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const renders = () => fetchSpy.mock.calls.map(([url]) => url);
|
||||
const settle = (
|
||||
view: ReturnType<typeof renderPreview>,
|
||||
steps: Array<"ready" | "missing" | "loaded">,
|
||||
@@ -437,7 +438,10 @@ describe("NLEPreview", () => {
|
||||
it("is rendered for the next open when the live frame is ready first", () => {
|
||||
const view = renderPreview();
|
||||
settle(view, ["ready", "missing"]);
|
||||
expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
|
||||
expect(renders()).toEqual([renderUrl]);
|
||||
expect(fetchSpy.mock.calls[0]?.[1]?.signal, "rendered as scheduler work").toBeInstanceOf(
|
||||
AbortSignal,
|
||||
);
|
||||
expect(view.stage.querySelector('[data-testid="preview-poster"]')).toBeNull();
|
||||
view.cleanup();
|
||||
});
|
||||
@@ -447,7 +451,7 @@ describe("NLEPreview", () => {
|
||||
settle(view, ["missing"]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
settle(view, ["ready", "ready"]);
|
||||
expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
|
||||
expect(renders()).toEqual([renderUrl]);
|
||||
view.cleanup();
|
||||
});
|
||||
|
||||
@@ -458,7 +462,7 @@ describe("NLEPreview", () => {
|
||||
settle(view, ["ready"]);
|
||||
view.render();
|
||||
settle(view, ["ready", "missing"]);
|
||||
expect(fetchSpy.mock.calls).toEqual([[renderUrl]]);
|
||||
expect(renders()).toEqual([renderUrl]);
|
||||
view.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { buildProjectApiPath } from "../../utils/projectRouting";
|
||||
import {
|
||||
createThumbnailKey,
|
||||
thumbnailScheduler,
|
||||
type ThumbnailRequest,
|
||||
} from "../../player/lib/thumbnailScheduler";
|
||||
import type { PreviewCompositionSize } from "../../utils/previewCompositionSize";
|
||||
|
||||
/** Frame 0 as the thumbnail route last rendered it; `cachedOnly` never starts a render. */
|
||||
@@ -65,7 +70,7 @@ export function usePreviewPoster(
|
||||
const renderMissingPoster = useCallback(() => {
|
||||
if (missingForRef.current !== visit || liveReadyForRef.current !== visit) return;
|
||||
missingForRef.current = null;
|
||||
void fetch(previewPosterUrl(projectId, false)).catch(() => {});
|
||||
renderPosterForNextOpen(projectId);
|
||||
}, [visit, projectId]);
|
||||
const onLiveReadyToShowChange = useCallback(
|
||||
(ready: boolean) => {
|
||||
@@ -91,6 +96,29 @@ export function usePreviewPoster(
|
||||
};
|
||||
}
|
||||
|
||||
/** Renders frame 0 under the thumbnail cap; the lease ends once the render settles. */
|
||||
export function renderPosterForNextOpen(projectId: string): void {
|
||||
const url = previewPosterUrl(projectId, false);
|
||||
const request: ThumbnailRequest = {
|
||||
key: createThumbnailKey({ kind: "poster-render", url }),
|
||||
projectId,
|
||||
sessionEpoch: 0,
|
||||
kind: "composition",
|
||||
priority: "overscan",
|
||||
discardWhenReleased: true,
|
||||
load: async (signal) => {
|
||||
await (await fetch(url, { signal })).arrayBuffer();
|
||||
return { value: { kind: "image", url, aspect: 16 / 9 }, weight: 0 };
|
||||
},
|
||||
};
|
||||
const settled = () => ["ready", "error"].includes(thumbnailScheduler.getSnapshot(request).status);
|
||||
let lease: { release(): void } | null = null;
|
||||
lease = thumbnailScheduler.acquire(request, () => {
|
||||
if (settled()) lease?.release();
|
||||
});
|
||||
if (settled()) lease.release();
|
||||
}
|
||||
|
||||
function useVisitNumber(key: string): number {
|
||||
const [seen, setSeen] = useState({ key, visit: 0 });
|
||||
if (seen.key === key) return seen.visit;
|
||||
|
||||
@@ -1,84 +1,18 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
(
|
||||
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
|
||||
).happyDOM.settings.disableIframePageLoading = true;
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
usePlayerStore.setState({ previewBooted: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.setState({ thumbnailRevisions: {} });
|
||||
});
|
||||
import { mountCompositionsTab } from "./compositionsTabTestUtils";
|
||||
|
||||
function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CompositionsTab
|
||||
projectId="demo"
|
||||
compositions={["compositions/headline.html"]}
|
||||
activeComposition={null}
|
||||
onSelect={onSelect}
|
||||
onAddToTimeline={onAddToTimeline}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const host = mountCompositionsTab({ onSelect, onAddToTimeline });
|
||||
const card = host.querySelector<HTMLElement>('[draggable="true"]');
|
||||
if (!card) throw new Error("composition card did not render");
|
||||
return { host, card, onSelect, onAddToTimeline };
|
||||
}
|
||||
|
||||
describe("composition card drag", () => {
|
||||
it("uses a cached image instead of eagerly mounting a live preview iframe", () => {
|
||||
const { host } = mount();
|
||||
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
||||
expect(thumbnail).not.toBeNull();
|
||||
expect(new URL(thumbnail?.src ?? "").searchParams.get("t")).toBe("3.00");
|
||||
expect(host.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a fallback when the cached thumbnail fails", () => {
|
||||
const { host } = mount();
|
||||
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
||||
if (!thumbnail) throw new Error("composition thumbnail did not render");
|
||||
|
||||
act(() => thumbnail.dispatchEvent(new Event("error")));
|
||||
|
||||
expect(host.textContent).toContain("Preview unavailable");
|
||||
expect(host.querySelector('img[src*="/thumbnail/"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("retries a failed thumbnail at the next persisted content revision", () => {
|
||||
const { host } = mount();
|
||||
const thumbnail = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
||||
if (!thumbnail) throw new Error("composition thumbnail did not render");
|
||||
act(() => thumbnail.dispatchEvent(new Event("error")));
|
||||
expect(host.textContent).toContain("Preview unavailable");
|
||||
|
||||
act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
|
||||
|
||||
const retry = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
|
||||
expect(retry).not.toBeNull();
|
||||
expect(new URL(retry?.src ?? "").searchParams.get("revision")).toBe("1");
|
||||
});
|
||||
|
||||
it("mounts one live preview only after sustained hover and removes it on leave", () => {
|
||||
vi.useFakeTimers();
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
@@ -1,57 +1,11 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
import { mountCompositionsTab } from "./compositionsTabTestUtils";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
(
|
||||
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
|
||||
).happyDOM.settings.disableIframePageLoading = true;
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.setState({ thumbnailRevisions: {} });
|
||||
});
|
||||
|
||||
function mount(compositions: string[], masterCompositionPath: string | null) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CompositionsTab
|
||||
projectId="demo"
|
||||
compositions={compositions}
|
||||
activeComposition={null}
|
||||
masterCompositionPath={masterCompositionPath}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
describe("CompositionsTab card thumbnails", () => {
|
||||
it("waits for the live preview to boot and keeps them through edit reloads", () => {
|
||||
usePlayerStore.getState().reset();
|
||||
const host = mount(["index.html"], "index.html");
|
||||
expect(host.querySelector("img")).toBeNull();
|
||||
|
||||
act(() => usePlayerStore.getState().markPreviewBooted());
|
||||
expect(host.querySelector("img")?.getAttribute("src")).toContain("/thumbnail/index.html");
|
||||
|
||||
act(() => usePlayerStore.getState().setTimelineReady(false));
|
||||
expect(host.querySelector("img")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
const mount = (compositions: string[], masterCompositionPath: string | null) =>
|
||||
mountCompositionsTab({ compositions, masterCompositionPath });
|
||||
|
||||
describe("CompositionsTab root badge", () => {
|
||||
it("marks the composition matching masterCompositionPath as root", () => {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { thumbnailScheduler } from "../../player/lib/thumbnailScheduler";
|
||||
import { TIMELINE_VIEWPORT_BUDGETS } from "../../player/lib/timelineViewportBudgets";
|
||||
import { CompositionThumbnail } from "../../player/components/CompositionThumbnail";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { renderPosterForNextOpen } from "../nle/PreviewPoster";
|
||||
import { mountCompositionsTab } from "./compositionsTabTestUtils";
|
||||
|
||||
class DecodingImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
naturalWidth = 16;
|
||||
naturalHeight = 9;
|
||||
set src(value: string) {
|
||||
if (value) queueMicrotask(() => this.onload?.());
|
||||
}
|
||||
}
|
||||
|
||||
let autoIntersect = true;
|
||||
const observed: Array<{
|
||||
element: Element;
|
||||
callback: IntersectionObserverCallback;
|
||||
root?: Element | Document | null;
|
||||
}> = [];
|
||||
class ViewportObserver {
|
||||
constructor(
|
||||
private readonly callback: IntersectionObserverCallback,
|
||||
private readonly options?: IntersectionObserverInit,
|
||||
) {}
|
||||
observe(element: Element) {
|
||||
observed.push({ element, callback: this.callback, root: this.options?.root });
|
||||
if (autoIntersect) this.enter();
|
||||
}
|
||||
enter() {
|
||||
const entry = { isIntersecting: true } as IntersectionObserverEntry;
|
||||
this.callback([entry], this as unknown as IntersectionObserver);
|
||||
}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
interface Render {
|
||||
url: URL;
|
||||
signal: AbortSignal;
|
||||
answer: (status?: number) => void;
|
||||
}
|
||||
|
||||
const real = {
|
||||
IntersectionObserver: globalThis.IntersectionObserver,
|
||||
fetch: globalThis.fetch,
|
||||
Image: globalThis.Image,
|
||||
create: URL.createObjectURL,
|
||||
revoke: URL.revokeObjectURL,
|
||||
};
|
||||
let renders: Render[] = [];
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
let frames = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
renders = [];
|
||||
[inFlight, peak, frames] = [0, 0, 0];
|
||||
globalThis.fetch = vi.fn(
|
||||
(input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((resolve, reject) => {
|
||||
const signal = init?.signal ?? new AbortController().signal;
|
||||
peak = Math.max(peak, ++inFlight);
|
||||
const end = () => (inFlight -= 1);
|
||||
signal.addEventListener("abort", () => {
|
||||
end();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
const answer = (status = 200) => {
|
||||
end();
|
||||
resolve(new Response(new Blob(["png"]), { status }));
|
||||
};
|
||||
renders.push({ url: new URL(String(input), window.location.origin), signal, answer });
|
||||
}),
|
||||
) as typeof fetch;
|
||||
globalThis.Image = DecodingImage as unknown as typeof Image;
|
||||
globalThis.IntersectionObserver = ViewportObserver as unknown as typeof IntersectionObserver;
|
||||
[autoIntersect, observed.length] = [true, 0];
|
||||
URL.createObjectURL = vi.fn(() => `blob:frame-${++frames}`);
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
usePlayerStore.setState({ previewBooted: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(globalThis, {
|
||||
fetch: real.fetch,
|
||||
Image: real.Image,
|
||||
IntersectionObserver: real.IntersectionObserver,
|
||||
});
|
||||
Object.assign(URL, { createObjectURL: real.create, revokeObjectURL: real.revoke });
|
||||
});
|
||||
|
||||
const mount = (compositions?: string[]) => mountCompositionsTab(compositions && { compositions });
|
||||
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 5)));
|
||||
const shown = (host: HTMLElement) => host.querySelector("img")?.getAttribute("src") ?? null;
|
||||
|
||||
async function nextRevisionShows(host: HTMLElement, revision: string, frame: string) {
|
||||
act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
|
||||
expect(renders.at(-1)!.url.searchParams.get("revision")).toBe(revision);
|
||||
renders.at(-1)!.answer();
|
||||
await settle();
|
||||
expect(shown(host)).toBe(frame);
|
||||
}
|
||||
|
||||
describe("composition card thumbnails", () => {
|
||||
it("render through the thumbnail scheduler once the preview boots, and never point an image at the route", async () => {
|
||||
usePlayerStore.getState().reset();
|
||||
const host = mount();
|
||||
expect(renders).toHaveLength(0);
|
||||
|
||||
act(() => usePlayerStore.getState().markPreviewBooted());
|
||||
expect(renders.map((r) => [r.url.pathname, r.url.searchParams.get("t")])).toEqual([
|
||||
["/api/projects/demo/thumbnail/compositions/headline.html", "3.00"],
|
||||
]);
|
||||
renders[0]!.answer();
|
||||
await settle();
|
||||
expect(shown(host)).toBe("blob:frame-1");
|
||||
expect(host.querySelector("iframe")).toBeNull();
|
||||
|
||||
act(() => usePlayerStore.getState().setTimelineReady(false));
|
||||
expect(shown(host)).toBe("blob:frame-1");
|
||||
});
|
||||
|
||||
it("wait until a card is near the viewport, so an offscreen card never renders ahead of a visible one", () => {
|
||||
autoIntersect = false;
|
||||
mount(["compositions/above.html", "compositions/seen.html", "compositions/below.html"]);
|
||||
expect(renders).toHaveLength(0);
|
||||
|
||||
const seen = observed.find((o) =>
|
||||
o.element.closest("[draggable]")?.textContent?.includes("seen"),
|
||||
)!;
|
||||
act(() =>
|
||||
seen.callback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
),
|
||||
);
|
||||
expect(renders.map((r) => r.url.pathname)).toEqual([
|
||||
"/api/projects/demo/thumbnail/compositions/seen.html",
|
||||
]);
|
||||
expect(seen.root).toBe(document.querySelector("[data-composition-list]"));
|
||||
});
|
||||
|
||||
it("keep showing the last frame, leased, until the next revision's frame is ready", async () => {
|
||||
const host = mount();
|
||||
renders[0]!.answer();
|
||||
await settle();
|
||||
act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
|
||||
expect(shown(host)).toBe("blob:frame-1");
|
||||
thumbnailScheduler.invalidateProject("demo");
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-1");
|
||||
|
||||
renders.at(-1)!.answer();
|
||||
await settle();
|
||||
expect(shown(host)).toBe("blob:frame-2");
|
||||
});
|
||||
|
||||
it("keep the last frame through a new revision even when the scheduler has evicted it", async () => {
|
||||
const host = mount();
|
||||
renders[0]!.answer();
|
||||
await settle();
|
||||
const fillers = Array.from({ length: 100 }, (_, i) =>
|
||||
thumbnailScheduler.acquire(
|
||||
{
|
||||
key: `filler-${i}`,
|
||||
projectId: "demo",
|
||||
sessionEpoch: 0,
|
||||
kind: "image",
|
||||
priority: "visible",
|
||||
load: async () => ({
|
||||
value: { kind: "image", url: `blob:filler-${i}`, aspect: 1 },
|
||||
weight: 1,
|
||||
}),
|
||||
},
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
while (
|
||||
thumbnailScheduler.getDiagnostics().queued + thumbnailScheduler.getDiagnostics().active >
|
||||
0
|
||||
) {
|
||||
await settle();
|
||||
}
|
||||
for (const filler of fillers) filler.release();
|
||||
|
||||
act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
|
||||
expect(renders.map((r) => r.url.searchParams.get("revision"))).toEqual(["0", "1"]);
|
||||
expect(shown(host)).toBe("blob:frame-1");
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-1");
|
||||
});
|
||||
|
||||
it("share one render with the timeline's thumbnail of the same composition", () => {
|
||||
mount();
|
||||
const timeline = createRoot(document.body.appendChild(document.createElement("div")));
|
||||
try {
|
||||
act(() =>
|
||||
timeline.render(
|
||||
<CompositionThumbnail
|
||||
previewUrl="/api/projects/demo/preview/comp/compositions/headline.html"
|
||||
label=""
|
||||
labelColor=""
|
||||
projectId="demo"
|
||||
sessionEpoch={usePlayerStore.getState().timelineSessionEpoch}
|
||||
seekTime={3}
|
||||
duration={0}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(renders).toHaveLength(1);
|
||||
} finally {
|
||||
act(() => timeline.unmount());
|
||||
}
|
||||
});
|
||||
|
||||
it("keep the cards and the poster render to the scheduler's cap, so Studio's own requests get a connection", async () => {
|
||||
const cap = TIMELINE_VIEWPORT_BUDGETS.concurrentCompositionFetches;
|
||||
mount(Array.from({ length: 6 }, (_, i) => `compositions/scene-${i}.html`));
|
||||
renderPosterForNextOpen("demo");
|
||||
expect(inFlight).toBe(cap);
|
||||
|
||||
while (renders.some((r) => !r.signal.aborted)) {
|
||||
renders.shift()!.answer();
|
||||
await settle();
|
||||
}
|
||||
expect(renders).toHaveLength(0);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(7);
|
||||
expect(peak).toBe(cap);
|
||||
});
|
||||
|
||||
it("abort a stale revision's render, and free its frame when the scheduler evicts it", async () => {
|
||||
const host = mount();
|
||||
const first = renders[0]!;
|
||||
await nextRevisionShows(host, "1", "blob:frame-1");
|
||||
expect(first.signal.aborted).toBe(true);
|
||||
await nextRevisionShows(host, "2", "blob:frame-2");
|
||||
|
||||
thumbnailScheduler.invalidateProject("demo");
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:frame-1");
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-2");
|
||||
});
|
||||
|
||||
it("show a fallback when the render fails, and retry it at the next content revision", async () => {
|
||||
const host = mount();
|
||||
renders[0]!.answer(500);
|
||||
await settle();
|
||||
expect(host.textContent).toContain("Preview unavailable");
|
||||
await nextRevisionShows(host, "1", "blob:frame-1");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
import { buildProjectApiPath } from "../../utils/projectRouting";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
||||
import {
|
||||
thumbnailScheduler,
|
||||
type ThumbnailRequest,
|
||||
type ThumbnailSnapshot,
|
||||
} from "../../player/lib/thumbnailScheduler";
|
||||
import {
|
||||
buildCompositionThumbnailUrl,
|
||||
compositionThumbnailRequest,
|
||||
resolveThumbnailSeekTime,
|
||||
THUMBNAIL_SEEK_TIME_SECONDS,
|
||||
} from "../../player/components/CompositionThumbnail";
|
||||
@@ -125,6 +132,38 @@ export function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay:
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrlOf = (snapshot: ThumbnailSnapshot) =>
|
||||
snapshot.status === "ready" && snapshot.value.kind === "image" ? snapshot.value.url : null;
|
||||
|
||||
// A layout effect takes the old frame's lease before the passive cleanup drops the first one.
|
||||
function useLastFrame(request: ThumbnailRequest | null, snapshot: ThumbnailSnapshot) {
|
||||
const current = imageUrlOf(snapshot);
|
||||
const [kept, setKept] = useState<{ request: ThumbnailRequest; url: string } | null>(null);
|
||||
if (request && current && kept?.request !== request) setKept({ request, url: current });
|
||||
useLayoutEffect(() => {
|
||||
if (!kept || kept.request === request) return;
|
||||
const lease = thumbnailScheduler.acquire(kept.request, () => {});
|
||||
return () => lease.release();
|
||||
}, [kept, request]);
|
||||
return current ?? kept?.url ?? null;
|
||||
}
|
||||
|
||||
function useNearViewport<T extends Element>(): [(element: T | null) => void, boolean] {
|
||||
const [element, setElement] = useState<T | null>(null);
|
||||
const [near, setNear] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!element || near) return;
|
||||
if (typeof IntersectionObserver === "undefined") return setNear(true);
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => entries.some((entry) => entry.isIntersecting) && setNear(true),
|
||||
{ root: element.closest("[data-composition-list]"), rootMargin: "200px" },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [element, near]);
|
||||
return [setElement, near];
|
||||
}
|
||||
|
||||
function CompCard({
|
||||
projectId,
|
||||
comp,
|
||||
@@ -153,7 +192,6 @@ function CompCard({
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
|
||||
const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
|
||||
const [failedThumbnailUrl, setFailedThumbnailUrl] = useState<string | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -192,7 +230,21 @@ function CompCard({
|
||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const previewUrl = compositionPreviewUrl(projectId, comp);
|
||||
const thumbnailUrl = compositionCardThumbnailUrl(projectId, comp, contentRevision);
|
||||
const thumbnailFailed = failedThumbnailUrl === thumbnailUrl;
|
||||
const [thumbnailBox, nearViewport] = useNearViewport<HTMLDivElement>();
|
||||
const timelineSessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
|
||||
const thumbnailRequest = useMemo(
|
||||
() =>
|
||||
previewBooted && nearViewport
|
||||
? compositionThumbnailRequest(thumbnailUrl, projectId, {
|
||||
sessionEpoch: timelineSessionEpoch,
|
||||
rich: true,
|
||||
})
|
||||
: null,
|
||||
[previewBooted, nearViewport, thumbnailUrl, projectId, timelineSessionEpoch],
|
||||
);
|
||||
const thumbnail = useThumbnailLease(thumbnailRequest);
|
||||
const frameUrl = useLastFrame(thumbnailRequest, thumbnail);
|
||||
const thumbnailFailed = thumbnail.status === "error";
|
||||
const previewScale = resolveCompositionPreviewScale({
|
||||
cardWidth: CARD_W,
|
||||
cardHeight: CARD_H,
|
||||
@@ -250,19 +302,20 @@ function CompCard({
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="w-20 h-[45px] rounded-sm overflow-hidden bg-neutral-900 shrink-0 relative">
|
||||
<div
|
||||
ref={thumbnailBox}
|
||||
className="w-20 h-[45px] rounded-sm overflow-hidden bg-neutral-900 shrink-0 relative"
|
||||
>
|
||||
{thumbnailFailed ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
|
||||
Preview unavailable
|
||||
</div>
|
||||
) : !previewBooted ? null : (
|
||||
) : !frameUrl ? null : (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
src={frameUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setFailedThumbnailUrl(thumbnailUrl)}
|
||||
className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
|
||||
livePreviewLoaded ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
@@ -405,7 +458,7 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto" data-composition-list>
|
||||
{compositions.map((comp) => (
|
||||
<CompCard
|
||||
key={`${projectId}:${comp}`}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { act, type ComponentProps } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { thumbnailScheduler } from "../../player/lib/thumbnailScheduler";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
(
|
||||
window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
|
||||
).happyDOM.settings.disableIframePageLoading = true;
|
||||
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
thumbnailScheduler.invalidateProject("demo");
|
||||
document.body.innerHTML = "";
|
||||
usePlayerStore.setState({ thumbnailRevisions: {} });
|
||||
});
|
||||
|
||||
export function mountCompositionsTab(props: Partial<ComponentProps<typeof CompositionsTab>> = {}) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<CompositionsTab
|
||||
projectId="demo"
|
||||
compositions={["compositions/headline.html"]}
|
||||
activeComposition={null}
|
||||
onSelect={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return host;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { memo, useMemo, type CSSProperties } from "react";
|
||||
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
|
||||
import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
|
||||
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
|
||||
import {
|
||||
createThumbnailKey,
|
||||
type ThumbnailPriority,
|
||||
type ThumbnailRequest,
|
||||
} from "../lib/thumbnailScheduler";
|
||||
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
|
||||
import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
|
||||
|
||||
@@ -85,6 +89,22 @@ export function compositionPathOfPreviewUrl(previewUrl: string): string {
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : "index.html";
|
||||
}
|
||||
|
||||
export function compositionThumbnailRequest(
|
||||
url: string,
|
||||
projectId: string,
|
||||
{ sessionEpoch = 0, priority = "visible", rich = false }: Partial<ThumbnailRequest> = {},
|
||||
): ThumbnailRequest {
|
||||
return {
|
||||
key: createThumbnailKey({ kind: "composition", url }),
|
||||
projectId,
|
||||
sessionEpoch,
|
||||
kind: "composition",
|
||||
priority,
|
||||
rich,
|
||||
load: (signal: AbortSignal) => loadCompositionImage(url, signal),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadCompositionImage(url: string, signal: AbortSignal) {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`Composition thumbnail failed (${response.status})`);
|
||||
@@ -132,15 +152,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
contentRevision,
|
||||
});
|
||||
const request = useMemo(
|
||||
() => ({
|
||||
key: createThumbnailKey({ kind: "composition", url }),
|
||||
projectId,
|
||||
sessionEpoch,
|
||||
kind: "composition" as const,
|
||||
priority,
|
||||
rich: true,
|
||||
load: (signal: AbortSignal) => loadCompositionImage(url, signal),
|
||||
}),
|
||||
() => compositionThumbnailRequest(url, projectId, { sessionEpoch, priority, rich: true }),
|
||||
[priority, projectId, sessionEpoch, url],
|
||||
);
|
||||
const snapshot = useThumbnailLease(request);
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface ThumbnailRequest {
|
||||
priority: ThumbnailPriority;
|
||||
/** Rich work is paused while the timeline is fast-scrolling. */
|
||||
rich?: boolean;
|
||||
/** For work whose result nobody reads: it is dropped, not cached, once its last lease ends. */
|
||||
discardWhenReleased?: boolean;
|
||||
load: (signal: AbortSignal) => Promise<ThumbnailLoadedResult>;
|
||||
}
|
||||
|
||||
@@ -214,10 +216,7 @@ export class ThumbnailScheduler {
|
||||
if (!scrolling) this.pump();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold server-rendered composition thumbnails while the preview loads a new document: both
|
||||
* are served by the same Studio server, and the preview is what the person is waiting for.
|
||||
*/
|
||||
/** Holds composition renders while the preview loads: same server, and the preview is what the person awaits. */
|
||||
setPreviewReloading(reloading: boolean): void {
|
||||
if (this.previewReloading === reloading) return;
|
||||
this.previewReloading = reloading;
|
||||
@@ -362,7 +361,7 @@ export class ThumbnailScheduler {
|
||||
const byteBudget = isWaveform
|
||||
? this.budgets.waveformCacheBytes
|
||||
: this.budgets.thumbnailCacheBytes;
|
||||
entry.cached = entry.weight <= byteBudget;
|
||||
entry.cached = !entry.request.discardWhenReleased && entry.weight <= byteBudget;
|
||||
if (!entry.cached) return;
|
||||
if (isWaveform) this.waveformCacheBytes += entry.weight;
|
||||
else this.cacheBytes += entry.weight;
|
||||
|
||||
@@ -145,7 +145,8 @@ async function waitForFiles(ctx, { from, want, timeout = 5000 }) {
|
||||
const now = readFiles(ctx.dir, ctx.files);
|
||||
if (!sameFiles(now, last)) [last, stableSince] = [now, Date.now()];
|
||||
const reached = want ? sameFiles(now, want) : !from || !sameFiles(now, from);
|
||||
if (reached && Date.now() - stableSince >= 300) return { reached: true, files: now };
|
||||
if (reached && Date.now() - stableSince >= 300)
|
||||
return { reached: true, files: now, at: stableSince };
|
||||
}
|
||||
return { reached: false, files: last };
|
||||
}
|
||||
@@ -634,7 +635,11 @@ export async function runCase({ browser, spec, dir, files, url, evidence }) {
|
||||
spec.gesture === "nudge"
|
||||
? await nudgeGesture(ctx, pre)
|
||||
: await pointerGesture(ctx, spec.gesture, pre);
|
||||
await waitForFiles(ctx, { from: original, timeout: spec.gesture === "nudge" ? 6000 : 5000 });
|
||||
const releasedAt = Date.now();
|
||||
const save = await waitForFiles(ctx, {
|
||||
from: original,
|
||||
timeout: spec.gesture === "nudge" ? 6000 : 5000,
|
||||
});
|
||||
await nextFrame(page, 2);
|
||||
await blurPreview(page);
|
||||
await page.keyboard.press("Escape");
|
||||
@@ -646,13 +651,15 @@ export async function runCase({ browser, spec, dir, files, url, evidence }) {
|
||||
// Undo and redo run before any reload. Each waits up to 15 s for its own write; redo waits for undo.
|
||||
const landed = (from) =>
|
||||
saved ? waitForFiles(ctx, { from, timeout: 15_000 }) : { reached: true, files: from };
|
||||
const undoKeyAt = Date.now();
|
||||
await chord(page, "Control+z");
|
||||
const undo = await landed(committedFiles);
|
||||
const undone = await settled(ctx);
|
||||
await shoot("undone");
|
||||
let [redo, redone] = [{ reached: false }, null];
|
||||
let [redo, redone, redoKeyAt] = [{ reached: false }, null, 0];
|
||||
if (undo.reached) {
|
||||
await blurPreview(page);
|
||||
redoKeyAt = Date.now();
|
||||
await chord(page, "Control+Shift+z");
|
||||
redo = await landed(undo.files);
|
||||
redone = await settled(ctx);
|
||||
@@ -683,7 +690,11 @@ export async function runCase({ browser, spec, dir, files, url, evidence }) {
|
||||
box: quadDistance(undone.visible, pre.visible),
|
||||
redoBytes: saved && redo.reached && sameFiles(redo.files, committedFiles),
|
||||
redoBox: redone && quadDistance(redone.visible, committed.visible),
|
||||
ms: undo.at ? undo.at - undoKeyAt : null,
|
||||
redoMs: redo.at ? redo.at - redoKeyAt : null,
|
||||
},
|
||||
// From release (or the last nudge key) to the edit's file write.
|
||||
saveMs: save.at ? save.at - releasedAt : null,
|
||||
// Which write never landed within 15 s; a redo that was never sent is untested, so undo fails.
|
||||
undoTimeout: saved && !undo.reached ? "undo" : saved && !redo.reached ? "redo" : null,
|
||||
smooth: { ...drive.smooth, control },
|
||||
|
||||
@@ -4,6 +4,8 @@ import { join } from "node:path";
|
||||
import { percentile } from "./geometry.mjs";
|
||||
|
||||
export const LIMIT_PX = 0.5;
|
||||
/** An undo or redo whose file write lands later than this after its key fails the undo check. */
|
||||
export const UNDO_WRITE_MAX_MS = 2000;
|
||||
// A frame over 1.5 vsyncs is dropped; raw rAF p95 stays reported so a different rule re-scores without a re-run.
|
||||
const DROPPED_FRAME_MS = 25;
|
||||
const WORK_MS = 8;
|
||||
@@ -53,7 +55,11 @@ export function score(spec, r) {
|
||||
drop: r.drop <= LIMIT_PX,
|
||||
reload: r.reload <= LIMIT_PX,
|
||||
render: r.render !== null && r.render <= LIMIT_PX,
|
||||
undo: r.undo.bytes && r.undo.redoBytes && Math.max(r.undo.box, r.undo.redoBox) <= LIMIT_PX,
|
||||
undo:
|
||||
r.undo.bytes &&
|
||||
r.undo.redoBytes &&
|
||||
Math.max(r.undo.box, r.undo.redoBox) <= LIMIT_PX &&
|
||||
!(Math.max(r.undo.ms ?? 0, r.undo.redoMs ?? 0) > UNDO_WRITE_MAX_MS),
|
||||
// Only drops beyond the blank page's, driven the same way in the same Chrome, are the edit's.
|
||||
smooth:
|
||||
smooth.dropped <= smooth.control.dropped &&
|
||||
@@ -116,6 +122,9 @@ function summarize(results, seconds) {
|
||||
perMetric,
|
||||
unsettled: measured.filter((r) => r.unsettled.length).length,
|
||||
undoTimeouts: measured.filter((r) => r.undoTimeout).length,
|
||||
undoSlow: measured.filter(
|
||||
(r) => Math.max(r.undo.ms ?? 0, r.undo.redoMs ?? 0) > UNDO_WRITE_MAX_MS,
|
||||
).length,
|
||||
renderErrors: measured.filter((r) => r.renderError).length,
|
||||
smooth: smoothSummary(measured),
|
||||
seconds: Math.round(seconds),
|
||||
@@ -138,6 +147,7 @@ function table(summary, meta, results) {
|
||||
`Undo or redo left different bytes in ${summary.bytesDiffer.undo} undo and ${summary.bytesDiffer.redo} redo cases.`,
|
||||
`The preview never held still for 1 s within 15 s in ${summary.unsettled} cases; the metrics that snapshot feeds fail.`,
|
||||
`An undo or redo write never landed within 15 s in ${summary.undoTimeouts} cases; undo fails there.`,
|
||||
`An undo or redo write landed later than ${UNDO_WRITE_MAX_MS} ms after its key in ${summary.undoSlow} cases; undo fails there.`,
|
||||
`The producer failed to render ${summary.renderErrors} cases; render fails there.`,
|
||||
`Smoothness: ${summary.smooth.unknown} cases with unknown work; dropped frames per case (median/max) ${summary.smooth.dropped}, blank-page control ${summary.smooth.control}; raw rAF p95 (median/max) ${summary.smooth.p95} ms, control ${summary.smooth.controlP95} ms.`,
|
||||
"",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { score, UNDO_WRITE_MAX_MS } from "./report.mjs";
|
||||
|
||||
const frames = { intervals: [16, 17, 16], work: [2, 3, 2] };
|
||||
const result = (undo) => ({
|
||||
tracking: { max: 0 },
|
||||
pressJump: 0,
|
||||
drop: 0,
|
||||
reload: 0,
|
||||
render: 0,
|
||||
undo: { bytes: true, redoBytes: true, box: 0, redoBox: 0, ...undo },
|
||||
smooth: { ...frames, control: frames },
|
||||
unsettled: [],
|
||||
});
|
||||
const undoCheck = (undo) => score({ id: "case" }, result(undo)).checks.undo;
|
||||
|
||||
describe("the undo check", () => {
|
||||
it("passes an undo and redo whose writes land within the limit of their keys", () => {
|
||||
expect(undoCheck({ ms: UNDO_WRITE_MAX_MS, redoMs: 80 })).toBe(true);
|
||||
});
|
||||
|
||||
it("fails an undo or a redo whose write lands later than the limit", () => {
|
||||
expect(undoCheck({ ms: UNDO_WRITE_MAX_MS + 1, redoMs: 80 })).toBe(false);
|
||||
expect(undoCheck({ ms: 80, redoMs: UNDO_WRITE_MAX_MS + 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
{
|
||||
"studio-open": {
|
||||
"browser": "153",
|
||||
"browser": "154",
|
||||
"counts": {
|
||||
"thumbnailRenders": 14,
|
||||
"total.frameNavigations": 7,
|
||||
"total.requests.document": 2,
|
||||
"total.requests.thumbnail": 22
|
||||
},
|
||||
"raised": {
|
||||
"total.requests.thumbnail": "#4401 shows a cached frame-0 poster while the preview boots: a cache-only ask that misses on a fresh open, then a render of that poster for the next open (+2 requests)"
|
||||
"total.requests.thumbnail": 15
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user