mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 05:28:31 +00:00
fix(studio-server): a project's signature no longer freezes the host while it walks the files (#7073)
* fix(studio-server): a project's signature no longer freezes the host while it walks the files * style(studio-server): format the signature changes * fix(studio-server): keep the adapter for the preview's early-error script
This commit is contained in:
@@ -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("<style>a{}</style>", "x"));
|
||||
expect(compositionsAffectedBy(dir, "index.html")).toBeNull();
|
||||
|
||||
compositionInputSignature(dir, "compositions/a.html", "s1");
|
||||
await compositionInputSignature(dir, "compositions/a.html", "s1");
|
||||
rewrite(dir, "<style>b{}</style>", "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("<style>a{}</style>", "x"));
|
||||
compositionInputSignature(dir, "compositions/a.html", "s1");
|
||||
await compositionInputSignature(dir, "compositions/a.html", "s1");
|
||||
rewrite(dir, "<style>a{}</style>", "moved");
|
||||
expect(compositionsAffectedBy(dir, "index.html")).toEqual(["index.html"]);
|
||||
rewrite(dir, "<style>a{}</style>", "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("<style>a{}</style>", "x"));
|
||||
compositionInputSignature(dir, "compositions/a.html", "s1");
|
||||
await compositionInputSignature(dir, "compositions/a.html", "s1");
|
||||
rewrite(dir, "<style>b{}</style>", "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("<style>a{}</style>", "x"));
|
||||
const before = compositionInputSignature(dir, "compositions/a.html", "s1");
|
||||
const before = await compositionInputSignature(dir, "compositions/a.html", "s1");
|
||||
rewrite(dir, "<style>a{}</style>", "moved");
|
||||
expect(compositionInputSignature(dir, "compositions/a.html", "s2")).toBe(before);
|
||||
expect(await compositionInputSignature(dir, "compositions/a.html", "s2")).toBe(before);
|
||||
rewrite(dir, "<style>b{}</style>", "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("<style>a{}</style>", "x"));
|
||||
const before = compositionInputSignature(dir, "index.html", "s1");
|
||||
const before = await compositionInputSignature(dir, "index.html", "s1");
|
||||
rewrite(dir, "<style>a{}</style>", "moved");
|
||||
expect(compositionInputSignature(dir, "index.html", "s2")).not.toBe(before);
|
||||
expect(await compositionInputSignature(dir, "index.html", "s2")).not.toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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;
|
||||
|
||||
@@ -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<string> {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
`<meta name="${GSAP_BASE_URL_META}" content="${escapeGsapUrl(options.gsapBaseUrl)}">`,
|
||||
),
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -122,7 +122,7 @@ export interface StudioApiAdapter {
|
||||
): Promise<string | null>;
|
||||
|
||||
/** Optional: a cached `createProjectSignature(dir)`; preview caching checks builds against it. */
|
||||
getProjectSignature?: (projectDir: string) => string;
|
||||
getProjectSignature?: (projectDir: string) => string | Promise<string>;
|
||||
invalidateProjectSignature?: (projectDir: string) => void;
|
||||
|
||||
loadSharp?: () => Promise<typeof import("sharp").default>;
|
||||
|
||||
Reference in New Issue
Block a user