mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat: cache shader transition preview frames (#634)
* feat: cache shader transition preview frames * fix: move shader transition loading to player
This commit is contained in:
@@ -95,6 +95,37 @@ describe("createPickerModule", () => {
|
||||
expect(api.getCandidatesAtPoint(Infinity, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not pick through blocking loading overlays", () => {
|
||||
const picker = createPickerModule({ postMessage: createMockPostMessage() });
|
||||
picker.installPickerApi();
|
||||
const scene = document.createElement("div");
|
||||
scene.id = "scene-title";
|
||||
scene.textContent = "Scene title";
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("data-hyper-shader-loading", "");
|
||||
const overlayLabel = document.createElement("span");
|
||||
overlayLabel.textContent = "Preparing scene transitions";
|
||||
overlay.appendChild(overlayLabel);
|
||||
document.body.appendChild(scene);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const originalElementsFromPoint = document.elementsFromPoint;
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => [overlayLabel, overlay, scene]),
|
||||
});
|
||||
|
||||
const api = (window as any).__HF_PICKER_API;
|
||||
try {
|
||||
expect(api.getCandidatesAtPoint(10, 10)).toEqual([]);
|
||||
} finally {
|
||||
Object.defineProperty(document, "elementsFromPoint", {
|
||||
configurable: true,
|
||||
value: originalElementsFromPoint,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("pickAtPoint returns null for invalid coords", () => {
|
||||
const picker = createPickerModule({ postMessage: createMockPostMessage() });
|
||||
picker.installPickerApi();
|
||||
|
||||
@@ -4,6 +4,19 @@ type PickerModuleDeps = {
|
||||
postMessage: (payload: RuntimeOutboundMessage) => void;
|
||||
};
|
||||
|
||||
const PICKER_IGNORE_SELECTOR = [
|
||||
"[data-hyperframes-ignore]",
|
||||
"[data-hyperframes-picker-ignore]",
|
||||
"[data-hf-ignore]",
|
||||
"[data-no-inspect]",
|
||||
"[data-no-pick]",
|
||||
"[data-hyper-shader-loading]",
|
||||
].join(",");
|
||||
const PICKER_BLOCK_SELECTOR = [
|
||||
"[data-hyperframes-picker-block]",
|
||||
"[data-hyper-shader-loading]",
|
||||
].join(",");
|
||||
|
||||
export type PickerModule = {
|
||||
enablePickMode: () => void;
|
||||
disablePickMode: () => void;
|
||||
@@ -48,9 +61,14 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") return false;
|
||||
if (el.classList.contains("__hf-pick-highlight")) return false;
|
||||
if (el.closest(PICKER_IGNORE_SELECTOR)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function blocksPickerAtPoint(el: Element | null): boolean {
|
||||
return Boolean(el?.closest(PICKER_BLOCK_SELECTOR));
|
||||
}
|
||||
|
||||
function buildElementSelector(el: Element): string {
|
||||
const htmlEl = el as HTMLElement;
|
||||
if (htmlEl.id) return `#${htmlEl.id}`;
|
||||
@@ -97,6 +115,7 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
const single = document.elementFromPoint(clientX, clientY);
|
||||
raw = single ? [single] : [];
|
||||
}
|
||||
if (blocksPickerAtPoint(raw[0] ?? null)) return [];
|
||||
const dedupe: Record<string, true> = {};
|
||||
const candidates: Element[] = [];
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { lstatSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { extname, isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
const SIGNATURE_TEXT_EXTENSIONS = new Set([
|
||||
".cjs",
|
||||
".css",
|
||||
".html",
|
||||
".js",
|
||||
".json",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".svg",
|
||||
".ts",
|
||||
".tsx",
|
||||
]);
|
||||
const SIGNATURE_EXCLUDED_DIRS = new Set([
|
||||
".cache",
|
||||
".git",
|
||||
".hyperframes",
|
||||
".next",
|
||||
".vite",
|
||||
"build",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"outputs",
|
||||
"renders",
|
||||
]);
|
||||
const MAX_SIGNATURE_TEXT_BYTES = 2_000_000;
|
||||
|
||||
interface ProjectSignatureFile {
|
||||
file: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
textContentEligible: boolean;
|
||||
}
|
||||
|
||||
interface ProjectSignatureCacheEntry {
|
||||
fingerprint: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
const projectSignatureCache = new Map<string, ProjectSignatureCacheEntry>();
|
||||
|
||||
function isPathWithin(parentDir: string, childPath: string): boolean {
|
||||
const childRelativePath = relative(parentDir, childPath);
|
||||
return (
|
||||
childRelativePath === "" ||
|
||||
(!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function isTextContentEligible(file: string, size: number): boolean {
|
||||
return (
|
||||
SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
function collectProjectSignatureFiles(
|
||||
projectDir: string,
|
||||
dir: string,
|
||||
files: ProjectSignatureFile[],
|
||||
): void {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir).sort();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
|
||||
const file = resolve(dir, entry);
|
||||
if (!isPathWithin(projectDir, file)) continue;
|
||||
let stat: ReturnType<typeof lstatSync>;
|
||||
try {
|
||||
stat = lstatSync(file);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isSymbolicLink()) continue;
|
||||
if (stat.isDirectory()) {
|
||||
collectProjectSignatureFiles(projectDir, file, files);
|
||||
} else if (stat.isFile()) {
|
||||
files.push({
|
||||
file,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
textContentEligible: isTextContentEligible(file, stat.size),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createProjectFingerprint(projectDir: string, files: ProjectSignatureFile[]): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const entry of files) {
|
||||
hash.update(relative(projectDir, entry.file));
|
||||
hash.update("\0");
|
||||
hash.update(String(entry.size));
|
||||
hash.update("\0");
|
||||
hash.update(String(entry.mtimeMs));
|
||||
hash.update("\0");
|
||||
hash.update(entry.textContentEligible ? "text" : "binary");
|
||||
hash.update("\0");
|
||||
}
|
||||
return hash.digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
export function createProjectSignature(projectDir: string): string {
|
||||
const normalizedProjectDir = resolve(projectDir);
|
||||
const files: ProjectSignatureFile[] = [];
|
||||
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files);
|
||||
files.sort((a, b) => a.file.localeCompare(b.file));
|
||||
|
||||
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
|
||||
const cached = projectSignatureCache.get(normalizedProjectDir);
|
||||
if (cached?.fingerprint === fingerprint) return cached.signature;
|
||||
|
||||
const hash = createHash("sha256");
|
||||
for (const entry of files) {
|
||||
const relativePath = relative(normalizedProjectDir, entry.file);
|
||||
hash.update(relativePath);
|
||||
hash.update("\0");
|
||||
hash.update(String(entry.size));
|
||||
hash.update("\0");
|
||||
if (entry.textContentEligible) {
|
||||
try {
|
||||
hash.update(readFileSync(entry.file));
|
||||
} catch {
|
||||
hash.update(String(entry.mtimeMs));
|
||||
}
|
||||
} else {
|
||||
hash.update(String(entry.mtimeMs));
|
||||
}
|
||||
hash.update("\0");
|
||||
}
|
||||
const signature = hash.digest("hex").slice(0, 24);
|
||||
projectSignatureCache.set(normalizedProjectDir, { fingerprint, signature });
|
||||
return signature;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { createStudioApi } from "./createStudioApi.js";
|
||||
export { createProjectSignature } from "./helpers/projectSignature.js";
|
||||
export type { StudioApiAdapter, ResolvedProject, RenderJobState, LintResult } from "./types.js";
|
||||
export { isSafePath, walkDir } from "./helpers/safePath.js";
|
||||
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { registerPreviewRoutes } from "./preview";
|
||||
import type { StudioApiAdapter } from "../types";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createProjectDir(): string {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-preview-test-"));
|
||||
tempDirs.push(projectDir);
|
||||
writeFileSync(join(projectDir, "index.html"), "<html><head></head><body>Preview</body></html>");
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
function createAdapter(
|
||||
projectDir: string,
|
||||
overrides: Partial<StudioApiAdapter> = {},
|
||||
): StudioApiAdapter {
|
||||
return {
|
||||
listProjects: () => [],
|
||||
resolveProject: async (id: string) => ({ id, dir: projectDir }),
|
||||
bundle: async () => null,
|
||||
lint: async () => ({ findings: [] }),
|
||||
runtimeUrl: "/api/runtime.js",
|
||||
rendersDir: () => "/tmp/renders",
|
||||
startRender: () => ({
|
||||
id: "job-1",
|
||||
status: "rendering",
|
||||
progress: 0,
|
||||
outputPath: "/tmp/out.mp4",
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function tryCreateSymlink(target: string, path: string, type: "dir" | "file"): boolean {
|
||||
try {
|
||||
symlinkSync(target, path, type);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPreviewSignature(projectDir: string): Promise<string> {
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir));
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/preview");
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
const match = /<meta name="hyperframes-project-signature" content="([^"]+)">/.exec(html);
|
||||
expect(match?.[1]).toBeTruthy();
|
||||
return match![1]!;
|
||||
}
|
||||
|
||||
describe("registerPreviewRoutes", () => {
|
||||
it("uses the adapter project signature when available", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const getProjectSignature = vi.fn(() => "cached-signature");
|
||||
const app = new Hono();
|
||||
registerPreviewRoutes(app, createAdapter(projectDir, { getProjectSignature }));
|
||||
|
||||
const response = await app.request("http://localhost/projects/demo/preview");
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getProjectSignature).toHaveBeenCalledWith(projectDir);
|
||||
expect(html).toContain(
|
||||
'<meta name="hyperframes-project-signature" content="cached-signature">',
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the preview signature after project text edits", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const file = join(projectDir, "scene.js");
|
||||
writeFileSync(file, "export const label = 'first';");
|
||||
|
||||
const firstSignature = await getPreviewSignature(projectDir);
|
||||
expect(await getPreviewSignature(projectDir)).toBe(firstSignature);
|
||||
|
||||
writeFileSync(file, "export const label = 'second with changed size';");
|
||||
|
||||
await expect(getPreviewSignature(projectDir)).resolves.not.toBe(firstSignature);
|
||||
});
|
||||
|
||||
it("skips symlinked files when creating the preview signature", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
const firstSignature = await getPreviewSignature(projectDir);
|
||||
|
||||
const externalDir = mkdtempSync(join(tmpdir(), "hf-preview-external-"));
|
||||
tempDirs.push(externalDir);
|
||||
const externalFile = join(externalDir, "external.js");
|
||||
writeFileSync(externalFile, "export const external = true;");
|
||||
|
||||
if (!tryCreateSymlink(externalFile, join(projectDir, "external.js"), "file")) return;
|
||||
|
||||
await expect(getPreviewSignature(projectDir)).resolves.toBe(firstSignature);
|
||||
});
|
||||
|
||||
it("skips symlinked directories when creating the preview signature", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
if (!tryCreateSymlink(projectDir, join(projectDir, "loop"), "dir")) return;
|
||||
|
||||
const signature = await getPreviewSignature(projectDir);
|
||||
|
||||
expect(signature).toMatch(/^[a-f0-9]{24}$/);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,25 @@ import type { StudioApiAdapter } from "../types.js";
|
||||
import { isSafePath } from "../helpers/safePath.js";
|
||||
import { getMimeType } from "../helpers/mime.js";
|
||||
import { buildSubCompositionHtml } from "../helpers/subComposition.js";
|
||||
import { createProjectSignature } from "../helpers/projectSignature.js";
|
||||
|
||||
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
|
||||
|
||||
function resolveProjectSignature(adapter: StudioApiAdapter, projectDir: string): string {
|
||||
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
|
||||
}
|
||||
|
||||
function injectProjectSignature(html: string, signature: string): string {
|
||||
const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
|
||||
if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) {
|
||||
return html.replace(
|
||||
new RegExp(`<meta\\s+name=["']${PROJECT_SIGNATURE_META}["'][^>]*>`, "i"),
|
||||
tag,
|
||||
);
|
||||
}
|
||||
if (html.includes("</head>")) return html.replace("</head>", `${tag}\n</head>`);
|
||||
return `${tag}\n${html}`;
|
||||
}
|
||||
|
||||
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
// Bundled composition preview
|
||||
@@ -37,10 +56,18 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
|
||||
}
|
||||
|
||||
bundled = injectProjectSignature(bundled, resolveProjectSignature(adapter, project.dir));
|
||||
return c.html(bundled);
|
||||
} catch {
|
||||
const file = resolve(project.dir, "index.html");
|
||||
if (existsSync(file)) return c.html(readFileSync(file, "utf-8"));
|
||||
if (existsSync(file)) {
|
||||
return c.html(
|
||||
injectProjectSignature(
|
||||
readFileSync(file, "utf-8"),
|
||||
resolveProjectSignature(adapter, project.dir),
|
||||
),
|
||||
);
|
||||
}
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
});
|
||||
@@ -63,7 +90,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
const baseHref = `/api/projects/${project.id}/preview/`;
|
||||
const html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
|
||||
if (!html) return c.text("not found", 404);
|
||||
return c.html(html);
|
||||
return c.html(injectProjectSignature(html, resolveProjectSignature(adapter, project.dir)));
|
||||
});
|
||||
|
||||
// Static asset serving (with range request support for audio/video seeking)
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface StudioApiAdapter {
|
||||
/** Bundle a project directory into a single HTML string. Returns null if unavailable. */
|
||||
bundle(projectDir: string): Promise<string | null>;
|
||||
|
||||
/** Optional: cached signature for project files that should invalidate preview frame caches. */
|
||||
getProjectSignature?: (projectDir: string) => string;
|
||||
|
||||
/** Lint a single HTML string. */
|
||||
lint(html: string, opts?: { filePath?: string }): Promise<LintResult> | LintResult;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user