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:
Miguel Ángel
2026-10-01 04:20:14 -07:00
committed by GitHub
parent 0db5b0a285
commit d0404b450c
13 changed files with 480 additions and 157 deletions
@@ -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);
});
});
+2 -5
View File
@@ -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
}
}
}