diff --git a/packages/studio-server/src/helpers/compositionInputs.test.ts b/packages/studio-server/src/helpers/compositionInputs.test.ts
index c2cdcfd4a4..227e30ce32 100644
--- a/packages/studio-server/src/helpers/compositionInputs.test.ts
+++ b/packages/studio-server/src/helpers/compositionInputs.test.ts
@@ -67,46 +67,46 @@ describe("a root write", () => {
const rewrite = (dir: string, head: string, body: string) =>
writeFileSync(join(dir, "index.html"), html(head, body));
- it("moves every composition when the head changed, or when no head was seen yet", () => {
+ it("moves every composition when the head changed, or when no head was seen yet", async () => {
const dir = project(files("", "x"));
expect(compositionsAffectedBy(dir, "index.html")).toBeNull();
- compositionInputSignature(dir, "compositions/a.html", "s1");
+ await compositionInputSignature(dir, "compositions/a.html", "s1");
rewrite(dir, "", "x");
expect(compositionsAffectedBy(dir, "index.html")).toBeNull();
});
- it("moves only the root when the head is as it was", () => {
+ it("moves only the root when the head is as it was", async () => {
const dir = project(files("", "x"));
- compositionInputSignature(dir, "compositions/a.html", "s1");
+ await compositionInputSignature(dir, "compositions/a.html", "s1");
rewrite(dir, "", "moved");
expect(compositionsAffectedBy(dir, "index.html")).toEqual(["index.html"]);
rewrite(dir, "", "moved again");
expect(compositionsAffectedBy(dir, "index.html")).toEqual(["index.html"]);
});
- it("tells every subscriber of one head edit the same thing, even after a thumbnail request", () => {
+ it("tells every subscriber of one head edit the same thing, even after a thumbnail request", async () => {
const dir = project(files("", "x"));
- compositionInputSignature(dir, "compositions/a.html", "s1");
+ await compositionInputSignature(dir, "compositions/a.html", "s1");
rewrite(dir, "", "x");
expect(compositionsAffectedBy(dir, "index.html")).toBeNull();
- compositionInputSignature(dir, "compositions/a.html", "s2");
+ await compositionInputSignature(dir, "compositions/a.html", "s2");
expect(compositionsAffectedBy(dir, "index.html")).toBeNull();
});
- it("leaves a scene's input signature alone for a body edit, not for a head edit", () => {
+ it("leaves a scene's input signature alone for a body edit, not for a head edit", async () => {
const dir = project(files("", "x"));
- const before = compositionInputSignature(dir, "compositions/a.html", "s1");
+ const before = await compositionInputSignature(dir, "compositions/a.html", "s1");
rewrite(dir, "", "moved");
- expect(compositionInputSignature(dir, "compositions/a.html", "s2")).toBe(before);
+ expect(await compositionInputSignature(dir, "compositions/a.html", "s2")).toBe(before);
rewrite(dir, "", "moved");
- expect(compositionInputSignature(dir, "compositions/a.html", "s3")).not.toBe(before);
+ expect(await compositionInputSignature(dir, "compositions/a.html", "s3")).not.toBe(before);
});
- it("changes the root's own input signature for a body edit", () => {
+ it("changes the root's own input signature for a body edit", async () => {
const dir = project(files("", "x"));
- const before = compositionInputSignature(dir, "index.html", "s1");
+ const before = await compositionInputSignature(dir, "index.html", "s1");
rewrite(dir, "", "moved");
- expect(compositionInputSignature(dir, "index.html", "s2")).not.toBe(before);
+ expect(await compositionInputSignature(dir, "index.html", "s2")).not.toBe(before);
});
});
diff --git a/packages/studio-server/src/helpers/compositionInputs.ts b/packages/studio-server/src/helpers/compositionInputs.ts
index ce18fd4d31..f5180ff9aa 100644
--- a/packages/studio-server/src/helpers/compositionInputs.ts
+++ b/packages/studio-server/src/helpers/compositionInputs.ts
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { parseHTML } from "linkedom";
-import { createProjectSignature } from "./projectSignature.js";
+import { createProjectSignatureAsync } from "./projectSignature.js";
import { rootHeadContent } from "./subComposition.js";
import { resolveWithinProject } from "./safePath.js";
import { descendants } from "./compositionInsertion.js";
@@ -99,11 +99,11 @@ function rootHashes(read: SourceReader) {
// What a thumbnail of `compPath` renders from: the project minus the compositions the root
// mounts that `compPath` does not; for a scene the root counts as its head alone.
-export function compositionInputSignature(
+export async function compositionInputSignature(
projectDir: string,
compPath: string,
projectSignature: string,
-): string {
+): Promise {
const key = `${projectDir}\0${compPath}`;
const known = inputSignatures.get(key);
if (known?.projectSignature === projectSignature) return known.inputSignature;
@@ -115,11 +115,12 @@ export function compositionInputSignature(
);
let inputSignature: string;
if (comp === ROOT_COMPOSITION) {
- inputSignature = createProjectSignature(projectDir, excluded);
+ inputSignature = await createProjectSignatureAsync(projectDir, excluded);
} else {
const hashes = rootHashes(read);
if (!rootMemos.has(projectDir)) rootMemos.set(projectDir, { ...hashes, affected: null });
- inputSignature = `${createProjectSignature(projectDir, excluded.add(ROOT_COMPOSITION))}:${hashes.head}`;
+ const rest = await createProjectSignatureAsync(projectDir, excluded.add(ROOT_COMPOSITION));
+ inputSignature = `${rest}:${hashes.head}`;
}
inputSignatures.set(key, { projectSignature, inputSignature });
return inputSignature;
diff --git a/packages/studio-server/src/helpers/projectSignature.ts b/packages/studio-server/src/helpers/projectSignature.ts
index c521efd456..9f46f5633b 100644
--- a/packages/studio-server/src/helpers/projectSignature.ts
+++ b/packages/studio-server/src/helpers/projectSignature.ts
@@ -260,8 +260,13 @@ export function listProjectFiles(
* provides one (the CLI invalidates its cache from the file watcher), falling
* back to a direct computation.
*/
-export function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
- return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
+export async function resolveProjectSignature(
+ adapter: StudioApiAdapter,
+ projectDir: string,
+): Promise {
+ return (
+ (await adapter.getProjectSignature?.(projectDir)) ?? createProjectSignatureAsync(projectDir)
+ );
}
/** The shared route opening: resolve the project (null → caller 404s) with its signature. */
@@ -271,7 +276,7 @@ export async function resolveProjectAndSignature(
): Promise<{ project: ResolvedProject; signature: string } | null> {
const project = await adapter.resolveProject(projectId);
if (!project) return null;
- return { project, signature: resolveProjectSignature(adapter, project.dir) };
+ return { project, signature: await resolveProjectSignature(adapter, project.dir) };
}
interface SignaturePlan {
diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts
index 9f42b58a11..8a2cf91bad 100644
--- a/packages/studio-server/src/routes/preview.ts
+++ b/packages/studio-server/src/routes/preview.ts
@@ -56,9 +56,8 @@ import {
isGsapCoreUrl,
} from "../helpers/gsapCoreScript.js";
import {
- createProjectSignature,
+ createProjectSignatureAsync,
resolveProjectAndSignature,
- resolveProjectSignature,
} from "../helpers/projectSignature.js";
import {
createStudioMotionRenderBodyScript,
@@ -369,6 +368,7 @@ kinds.push({name:known[name]===1?name:"other",origin:!path?"unknown":path===runt
function injectStudioPreviewAugmentations(
html: string,
adapter: StudioApiAdapter,
+ signature: string,
projectDir: string,
activeCompositionPath: string,
options: { capture: boolean; gsapBaseUrl: string },
@@ -388,7 +388,7 @@ function injectStudioPreviewAugmentations(
marked,
``,
),
- resolveProjectSignature(adapter, projectDir),
+ signature,
),
options.gsapBaseUrl,
);
@@ -536,6 +536,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
bundled = injectStudioPreviewAugmentations(
ensureHfIds(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
adapter,
+ signature,
project.dir,
mainCompositionPath,
{ capture, gsapBaseUrl },
@@ -549,7 +550,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
mediaCodecProbeCache,
);
bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
- if (createProjectSignature(project.dir) === signature) {
+ if ((await createProjectSignatureAsync(project.dir)) === signature) {
rememberPreview(builtKey, bundled);
if (!capture) adapter.previewDocuments?.write(builtKey, bundled);
}
@@ -566,6 +567,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
let fallbackAugmented = injectStudioPreviewAugmentations(
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
adapter,
+ signature,
project.dir,
fallback.compositionPath,
{ capture, gsapBaseUrl },
@@ -686,7 +688,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
html = withRootImportMap(html, baseHref);
recordPreviewReferences(project.dir, html);
html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
- html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, {
+ html = injectStudioPreviewAugmentations(html, adapter, signature, project.dir, compPath, {
capture,
gsapBaseUrl,
});
diff --git a/packages/studio-server/src/routes/projects.test.ts b/packages/studio-server/src/routes/projects.test.ts
index a484bc2649..e3be4bacf7 100644
--- a/packages/studio-server/src/routes/projects.test.ts
+++ b/packages/studio-server/src/routes/projects.test.ts
@@ -71,6 +71,29 @@ describe("GET /projects/:id/signature", () => {
expect(await response.json()).toEqual({ signature: "cached-sig" });
});
+ // fails without the fix: the polled signature walked every file synchronously, holding a host's event loop (a
+ // desktop app's main process) for the whole walk.
+ it("lets the event loop run while it walks a project", async () => {
+ const projectDir = createProjectDir();
+ for (let i = 0; i < 300; i++)
+ writeFileSync(join(projectDir, `clip-${i}.html`), COMPOSITION_HTML);
+ const app = new Hono();
+ registerProjectRoutes(app, createAdapter(projectDir));
+ let turns = 0;
+ let answered = false;
+ const turn = () => {
+ if (answered) return;
+ turns++;
+ setImmediate(turn);
+ };
+ setImmediate(turn);
+
+ const response = await app.request("http://localhost/projects/demo/signature");
+ answered = true;
+ expect(response.status).toBe(200);
+ expect(turns).toBeGreaterThan(0);
+ });
+
it("computes a signature that moves when project files change", async () => {
const projectDir = createProjectDir();
const app = new Hono();
diff --git a/packages/studio-server/src/routes/projects.ts b/packages/studio-server/src/routes/projects.ts
index 70e0af4b82..291c1e2b95 100644
--- a/packages/studio-server/src/routes/projects.ts
+++ b/packages/studio-server/src/routes/projects.ts
@@ -43,7 +43,7 @@ export function registerProjectRoutes(api: Hono, adapter: StudioApiAdapter): voi
api.get("/projects/:id/signature", async (c) => {
const project = await adapter.resolveProject(c.req.param("id"));
if (!project) return c.json({ error: "not found" }, 404);
- return c.json({ signature: resolveProjectSignature(adapter, project.dir) });
+ return c.json({ signature: await resolveProjectSignature(adapter, project.dir) });
});
// Project file tree
diff --git a/packages/studio-server/src/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts
index 41dd36410d..7d355a0c17 100644
--- a/packages/studio-server/src/routes/thumbnail.ts
+++ b/packages/studio-server/src/routes/thumbnail.ts
@@ -17,7 +17,10 @@ import { createHash, randomUUID } from "node:crypto";
import type { StudioApiAdapter } from "../types.js";
import { STUDIO_MANUAL_EDITS_PATH } from "../helpers/manualEditsRenderScript.js";
import { compositionInputSignature } from "../helpers/compositionInputs.js";
-import { createProjectSignature, resolveProjectAndSignature } from "../helpers/projectSignature.js";
+import {
+ createProjectSignatureAsync,
+ resolveProjectAndSignature,
+} from "../helpers/projectSignature.js";
import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js";
import { thumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator.js";
import { requestSubPath } from "../helpers/requestSubPath.js";
@@ -134,7 +137,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
const source = htmlFile ? readFileOnce(htmlFile) : "not-a-file";
if (source === "not-a-file") return c.json({ error: "not found" }, 404);
// Keyed on what this composition renders from, so editing one scene leaves the others cached.
- const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
+ const inputSignature = await compositionInputSignature(project.dir, compPath, projectSignature);
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
const rawSeekTime = url.searchParams.get("t");
@@ -246,15 +249,15 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v
if (!generated) return null;
const previewCopiesAtEnd = proxyActivityMark(project.dir);
const afterGeneration = await resolveProjectAndSignature(adapter, project.id);
- const inputsUnchanged = (signature: string) =>
- compositionInputSignature(project.dir, compPath, signature) === inputSignature;
+ const inputsUnchanged = async (signature: string) =>
+ (await compositionInputSignature(project.dir, compPath, signature)) === inputSignature;
// Both the adapter's signature and a fresh one: the adapter's can lag the watcher.
if (
previewCopiesAtStart === null ||
previewCopiesAtEnd !== previewCopiesAtStart ||
afterGeneration?.project.dir !== project.dir ||
- !inputsUnchanged(afterGeneration.signature) ||
- !inputsUnchanged(createProjectSignature(project.dir))
+ !(await inputsUnchanged(afterGeneration.signature)) ||
+ !(await inputsUnchanged(await createProjectSignatureAsync(project.dir)))
) {
// The browser may have rendered content written after this request
// captured its cache identity. Return the pixels to this caller,
diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts
index 85efaf974d..83da1f74cf 100644
--- a/packages/studio-server/src/types.ts
+++ b/packages/studio-server/src/types.ts
@@ -122,7 +122,7 @@ export interface StudioApiAdapter {
): Promise;
/** Optional: a cached `createProjectSignature(dir)`; preview caching checks builds against it. */
- getProjectSignature?: (projectDir: string) => string;
+ getProjectSignature?: (projectDir: string) => string | Promise;
invalidateProjectSignature?: (projectDir: string) => void;
loadSharp?: () => Promise;