Skip to content

Commit 8cb7d06

Browse files
perf(studio): re-render only the edited scene's thumbnails after an edit (#4315)
* perf(studio): refresh only the edited scene's thumbnails and let the preview load first One scene edit regenerated every sidebar and timeline thumbnail, and those renders starved the new preview's requests on the Studio server. The thumbnail cache now keys on what a composition renders from, the server names the compositions a write affects, only those thumbnails refresh, and composition thumbnails wait while a shadow preview reload is in flight. * test(studio): pin scene-scoped thumbnail reuse and the preview-reload hold Memoizes the scoped thumbnail signature on the project signature so requests keep reusing the adapter's cached one, keeps the fresh-signature check that catches a write the watcher has not reported yet, and adds tests for sibling-scene reuse, root-head invalidation, the affected-composition set, per-path revisions and the scheduler hold. * refactor(studio): share the file-change payload guard * fix(studio): match thumbnail revisions for scenes mounted with a ./ path * perf(studio): keep signature cache keys constant-size * style(studio): format thumbnail-scope changes * docs(studio): trim thumbnail input comments
1 parent 8055961 commit 8cb7d06

24 files changed

Lines changed: 497 additions & 63 deletions

‎packages/cli/src/server/studioServer.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
fileContentVersion,
4040
getMimeType,
4141
affectsProjectSignature,
42+
compositionsAffectedBy,
4243
type PreviewApiAdapter,
4344
thumbnailDeviceScaleFactor,
4445
type ResolvedProject,
@@ -834,7 +835,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
834835
stream
835836
.writeSSE({
836837
event: "file-change",
837-
data: JSON.stringify({ path, version, projectId: project.id, ...receipt }),
838+
data: JSON.stringify({
839+
path,
840+
version,
841+
projectId: project.id,
842+
// Which thumbnails this write can change; null means all of them.
843+
affectedCompositions: compositionsAffectedBy(projectDir, path),
844+
...receipt,
845+
}),
838846
})
839847
.catch(() => {});
840848
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { compositionsAffectedBy } from "./compositionInputs";
6+
7+
const dirs: string[] = [];
8+
afterEach(() => {
9+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
10+
});
11+
12+
function project(files: Record<string, string>): string {
13+
const dir = mkdtempSync(join(tmpdir(), "hf-composition-inputs-"));
14+
dirs.push(dir);
15+
for (const [path, content] of Object.entries(files)) {
16+
mkdirSync(join(dir, path, ".."), { recursive: true });
17+
writeFileSync(join(dir, path), content);
18+
}
19+
return dir;
20+
}
21+
22+
const mount = (src: string) => `<div data-composition-src="${src}"></div>`;
23+
24+
describe("compositionsAffectedBy", () => {
25+
const dir = () =>
26+
project({
27+
"index.html": mount("compositions/a.html") + mount("./compositions/b.html"),
28+
"compositions/a.html": `<template>${mount("compositions/nested.html")}</template>`,
29+
"compositions/b.html": "<template>b</template>",
30+
"compositions/nested.html": "<template>nested</template>",
31+
"compositions/unmounted.html": "<template>draft</template>",
32+
"assets/logo.svg": "<svg/>",
33+
});
34+
35+
it("names a scene and the compositions that mount it, not its siblings", () => {
36+
expect(compositionsAffectedBy(dir(), "compositions/b.html")).toEqual([
37+
"index.html",
38+
"compositions/b.html",
39+
]);
40+
expect(compositionsAffectedBy(dir(), "compositions/nested.html")).toEqual([
41+
"index.html",
42+
"compositions/a.html",
43+
"compositions/nested.html",
44+
]);
45+
});
46+
47+
it("reaches every composition from the root, an asset, or a file the root does not mount", () => {
48+
expect(compositionsAffectedBy(dir(), "index.html")).toBeNull();
49+
expect(compositionsAffectedBy(dir(), "assets/logo.svg")).toBeNull();
50+
expect(compositionsAffectedBy(dir(), "compositions/unmounted.html")).toBeNull();
51+
});
52+
53+
it("reads watcher paths with either separator", () => {
54+
expect(compositionsAffectedBy(dir(), "compositions\\b.html")).toEqual([
55+
"index.html",
56+
"compositions/b.html",
57+
]);
58+
});
59+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { readFileSync } from "node:fs";
2+
import { createProjectSignature } from "./projectSignature.js";
3+
import { resolveWithinProject } from "./safePath.js";
4+
5+
const ROOT_COMPOSITION = "index.html";
6+
// A regex, not a parser: a stray match (say, inside a script string) only widens what a
7+
// change invalidates, never narrows it.
8+
const COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
9+
10+
type SourceReader = (compPath: string) => string | null;
11+
12+
function projectReader(projectDir: string): SourceReader {
13+
const sources = new Map<string, string | null>();
14+
return (compPath) => {
15+
if (!sources.has(compPath)) {
16+
const file = resolveWithinProject(projectDir, compPath);
17+
let source: string | null = null;
18+
try {
19+
source = file ? readFileSync(file, "utf-8") : null;
20+
} catch {
21+
// Missing or unreadable: it mounts nothing, and still counts as mounted by its host.
22+
}
23+
sources.set(compPath, source);
24+
}
25+
return sources.get(compPath) ?? null;
26+
};
27+
}
28+
29+
function normalizeSource(src: string): string | null {
30+
if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//")) return null;
31+
const path = src.split(/[?#]/)[0]?.replace(/^\.?\//, "") ?? "";
32+
return path || null;
33+
}
34+
35+
/** `compPath` plus every composition it mounts, transitively, resolved from the project root as the bundler does. */
36+
function closureOf(read: SourceReader, compPath: string): Set<string> {
37+
const closure = new Set<string>();
38+
const visit = (path: string) => {
39+
if (closure.has(path)) return;
40+
closure.add(path);
41+
for (const match of (read(path) ?? "").matchAll(COMPOSITION_SRC)) {
42+
const mounted = normalizeSource(match[2] ?? "");
43+
if (mounted) visit(mounted);
44+
}
45+
};
46+
visit(compPath);
47+
return closure;
48+
}
49+
50+
// ponytail: this and projectSignature's per-exclusion cache keep one small entry per (project,
51+
// composition) ever thumbnailed, never evicted; LRU them if a server ever holds thousands.
52+
const inputSignatures = new Map<string, { projectSignature: string; inputSignature: string }>();
53+
54+
// What a thumbnail of `compPath` renders from: the project minus the compositions the root
55+
// mounts that `compPath` does not. The root always counts (previews use its head).
56+
export function compositionInputSignature(
57+
projectDir: string,
58+
compPath: string,
59+
projectSignature: string,
60+
): string {
61+
const key = `${projectDir}\0${compPath}`;
62+
const known = inputSignatures.get(key);
63+
if (known?.projectSignature === projectSignature) return known.inputSignature;
64+
const read = projectReader(projectDir);
65+
const inputs = closureOf(read, normalizeSource(compPath) ?? compPath).add(ROOT_COMPOSITION);
66+
const siblings = [...closureOf(read, ROOT_COMPOSITION)].filter((path) => !inputs.has(path));
67+
const inputSignature = createProjectSignature(projectDir, new Set(siblings));
68+
inputSignatures.set(key, { projectSignature, inputSignature });
69+
return inputSignature;
70+
}
71+
72+
/**
73+
* Compositions whose rendered frames a write at `changedPath` can change, or `null` for all
74+
* of them: the root, assets and any file the root does not mount reach every composition.
75+
*/
76+
export function compositionsAffectedBy(projectDir: string, changedPath: string): string[] | null {
77+
const changed = changedPath.replace(/\\/g, "/");
78+
const read = projectReader(projectDir);
79+
const mounted = closureOf(read, ROOT_COMPOSITION);
80+
if (changed === ROOT_COMPOSITION || !mounted.has(changed)) return null;
81+
return [...mounted].filter((path) => closureOf(read, path).has(changed));
82+
}

‎packages/studio-server/src/helpers/projectSignature.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,16 +197,28 @@ export async function resolveProjectAndSignature(
197197

198198
/**
199199
* Creates a stable preview cache-busting signature for project source plus Studio manifests.
200+
* `excluding` (project-relative paths) leaves those files out.
200201
*/
201-
export function createProjectSignature(projectDir: string): string {
202+
export function createProjectSignature(
203+
projectDir: string,
204+
excluding: ReadonlySet<string> = new Set(),
205+
): string {
202206
const normalizedProjectDir = resolve(projectDir);
203-
const files: ProjectSignatureFile[] = [];
204-
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files);
205-
collectProjectSignatureManifestFiles(normalizedProjectDir, files);
207+
const collected: ProjectSignatureFile[] = [];
208+
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
209+
collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
210+
const files = collected.filter(
211+
(entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/")),
212+
);
206213
files.sort((a, b) => a.file.localeCompare(b.file));
207214

208215
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
209-
const cached = projectSignatureCache.get(normalizedProjectDir);
216+
const cacheKey = excluding.size
217+
? `${normalizedProjectDir}\0${createHash("sha256")
218+
.update([...excluding].sort().join("\0"))
219+
.digest("hex")}`
220+
: normalizedProjectDir;
221+
const cached = projectSignatureCache.get(cacheKey);
210222
if (cached?.fingerprint === fingerprint) return cached.signature;
211223

212224
const hash = createHash("sha256");
@@ -228,6 +240,6 @@ export function createProjectSignature(projectDir: string): string {
228240
hash.update("\0");
229241
}
230242
const signature = hash.digest("hex").slice(0, 24);
231-
projectSignatureCache.set(normalizedProjectDir, { fingerprint, signature });
243+
projectSignatureCache.set(cacheKey, { fingerprint, signature });
232244
return signature;
233245
}

‎packages/studio-server/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { createStudioApi } from "./createStudioApi.js";
22
export { createProjectSignature, affectsProjectSignature } from "./helpers/projectSignature.js";
3+
export { compositionsAffectedBy } from "./helpers/compositionInputs.js";
34
export type {
45
StudioApiAdapter,
56
ResolvedProject,

‎packages/studio-server/src/routes/thumbnail.test.ts‎

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
existsSync,
55
mkdirSync,
66
mkdtempSync,
7+
readFileSync,
78
readdirSync,
89
rmSync,
910
truncateSync,
@@ -317,6 +318,54 @@ describe("registerThumbnailRoutes", () => {
317318
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
318319
});
319320

321+
function writeTwoScenes(dir: string): { sceneA: string; sceneB: string } {
322+
writeFileSync(
323+
join(dir, "index.html"),
324+
`<head><style>body { margin: 0 }</style></head><div data-width="640" data-height="360">` +
325+
`<div data-composition-src="compositions/a.html"></div>` +
326+
`<div data-composition-src="compositions/b.html"></div></div>`,
327+
);
328+
mkdirSync(join(dir, "compositions"), { recursive: true });
329+
const sceneA = join(dir, "compositions", "a.html");
330+
const sceneB = join(dir, "compositions", "b.html");
331+
writeFileSync(sceneA, `<template><div data-composition-id="a">A</div></template>`);
332+
writeFileSync(sceneB, `<template><div data-composition-id="b">B</div></template>`);
333+
return { sceneA, sceneB };
334+
}
335+
336+
it("keeps a scene's cached thumbnail when a sibling scene changes", async () => {
337+
const adapter = createAdapter();
338+
const project = await adapter.resolveProject("demo");
339+
if (!project) throw new Error("missing project");
340+
const app = new Hono();
341+
registerThumbnailRoutes(app, adapter);
342+
const { sceneA } = writeTwoScenes(project.dir);
343+
const url = "http://localhost/projects/demo/thumbnail/compositions/b.html?t=2&v=test";
344+
345+
await app.request(url);
346+
writeFileSync(sceneA, `<template><div data-composition-id="a">A, edited</div></template>`);
347+
await app.request(url);
348+
349+
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(1);
350+
});
351+
352+
it("regenerates a scene thumbnail when the root it is built on changes", async () => {
353+
const adapter = createAdapter();
354+
const project = await adapter.resolveProject("demo");
355+
if (!project) throw new Error("missing project");
356+
const app = new Hono();
357+
registerThumbnailRoutes(app, adapter);
358+
writeTwoScenes(project.dir);
359+
const url = "http://localhost/projects/demo/thumbnail/compositions/b.html?t=2&v=test";
360+
361+
await app.request(url);
362+
const root = join(project.dir, "index.html");
363+
writeFileSync(root, readFileSync(root, "utf-8").replace("margin: 0", "margin: 4px"));
364+
await app.request(url);
365+
366+
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
367+
});
368+
320369
it("regenerates a thumbnail when imported CSS changes", async () => {
321370
const adapter = createAdapter();
322371
const project = await adapter.resolveProject("demo");
@@ -365,26 +414,51 @@ describe("registerThumbnailRoutes", () => {
365414
expect(getProjectSignature).toHaveBeenNthCalledWith(1, project.dir);
366415
});
367416

368-
it("does not cache generated pixels under a signature that changed in flight", async () => {
417+
it("does not cache generated pixels when the composition's inputs changed in flight", async () => {
369418
const adapter = createAdapter();
370419
const project = await adapter.resolveProject("demo");
371420
if (!project) throw new Error("missing project");
372-
const signatures = ["old", "new", "old", "old"];
373-
adapter.getProjectSignature = vi.fn(() => signatures.shift() ?? "old");
421+
const { sceneB } = writeTwoScenes(project.dir);
374422
adapter.generateThumbnail = vi
375423
.fn()
376-
.mockResolvedValueOnce(Buffer.from("rendered-after-change"))
377-
.mockResolvedValueOnce(Buffer.from("rendered-old"));
424+
.mockImplementationOnce(async () => {
425+
writeFileSync(sceneB, `<template><div data-composition-id="b">B, edited</div></template>`);
426+
return Buffer.from("rendered-after-change");
427+
})
428+
.mockResolvedValueOnce(Buffer.from("rendered-current"));
378429
const app = new Hono();
379430
registerThumbnailRoutes(app, adapter);
380-
const url = "http://localhost/projects/demo/thumbnail/index.html?t=3";
431+
const url = "http://localhost/projects/demo/thumbnail/compositions/b.html?t=3";
381432

382433
expect(await (await app.request(url)).text()).toBe("rendered-after-change");
383434
expect(existsSync(join(project.dir, ".thumbnails"))).toBe(false);
384-
expect(await (await app.request(url)).text()).toBe("rendered-old");
435+
expect(await (await app.request(url)).text()).toBe("rendered-current");
385436
expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2);
386437
});
387438

439+
it("does not cache pixels changed in flight behind a stale adapter signature", async () => {
440+
const adapter = createAdapter();
441+
const project = await adapter.resolveProject("demo");
442+
if (!project) throw new Error("missing project");
443+
const { sceneB } = writeTwoScenes(project.dir);
444+
const stale = createProjectSignature(project.dir);
445+
adapter.getProjectSignature = () => stale;
446+
adapter.generateThumbnail = vi
447+
.fn()
448+
.mockImplementationOnce(async () => {
449+
writeFileSync(sceneB, `<template><div data-composition-id="b">B, edited</div></template>`);
450+
return Buffer.from("rendered-after-change");
451+
})
452+
.mockResolvedValueOnce(Buffer.from("rendered-again"));
453+
const app = new Hono();
454+
registerThumbnailRoutes(app, adapter);
455+
const url = "http://localhost/projects/demo/thumbnail/compositions/b.html?t=3";
456+
457+
expect(await (await app.request(url)).text()).toBe("rendered-after-change");
458+
expect(existsSync(join(project.dir, ".thumbnails"))).toBe(false);
459+
expect(await (await app.request(url)).text()).toBe("rendered-again");
460+
});
461+
388462
it("keeps changed studio motion separated in the disk cache", async () => {
389463
const adapter = createAdapter();
390464
const project = await adapter.resolveProject("demo");

‎packages/studio-server/src/routes/thumbnail.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { join } from "node:path";
1414
import { createHash, randomUUID } from "node:crypto";
1515
import type { StudioApiAdapter } from "../types.js";
1616
import { STUDIO_MANUAL_EDITS_PATH } from "../helpers/manualEditsRenderScript.js";
17+
import { compositionInputSignature } from "../helpers/compositionInputs.js";
1718
import { createProjectSignature, resolveProjectAndSignature } from "../helpers/projectSignature.js";
1819
import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js";
1920
import { thumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator.js";
@@ -86,6 +87,8 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
8687
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? "",
8788
);
8889
if (compPath && !compPath.includes(".")) compPath += ".html";
90+
// Keyed on what this composition renders from, so editing one scene leaves the others cached.
91+
const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
8992

9093
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
9194
const rawSeekTime = url.searchParams.get("t");
@@ -159,14 +162,14 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
159162
const urlVersionKey = urlVersion
160163
? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}`
161164
: "";
162-
const projectSignatureKey = `_${createHash("sha1").update(projectSignature).digest("hex").slice(0, 16)}`;
165+
const inputSignatureKey = `_${createHash("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
163166
const outputScale =
164167
outputMode === "source"
165168
? 1
166169
: Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
167170
const outputWidth = Math.max(1, Math.round(compW * outputScale));
168171
const outputHeight = Math.max(1, Math.round(compH * outputScale));
169-
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${projectSignatureKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
172+
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
170173
const cachePath = join(cacheDir, cacheKey);
171174
if (!prunedCacheDirs.has(cacheDir)) {
172175
prunedCacheDirs.add(cacheDir);
@@ -202,12 +205,13 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
202205
});
203206
if (!generated) return null;
204207
const afterGeneration = await resolveProjectAndSignature(adapter, project.id);
205-
const freshSignature = createProjectSignature(project.dir);
208+
const inputsUnchanged = (signature: string) =>
209+
compositionInputSignature(project.dir, compPath, signature) === inputSignature;
210+
// Both the adapter's signature and a fresh one: the adapter's can lag the watcher.
206211
if (
207-
!afterGeneration ||
208-
afterGeneration.project.dir !== project.dir ||
209-
afterGeneration.signature !== projectSignature ||
210-
freshSignature !== projectSignature
212+
afterGeneration?.project.dir !== project.dir ||
213+
!inputsUnchanged(afterGeneration.signature) ||
214+
!inputsUnchanged(createProjectSignature(project.dir))
211215
) {
212216
// The browser may have rendered content written after this request
213217
// captured its cache identity. Return the pixels to this caller,

0 commit comments

Comments
 (0)