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:
Vance Ingalls
2026-05-06 01:25:09 -07:00
committed by GitHub
parent 64457d9fe7
commit a7b308b667
23 changed files with 3475 additions and 243 deletions
+31
View File
@@ -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();
+19
View File
@@ -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
View File
@@ -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}$/);
});
});
+29 -2
View File
@@ -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)
+3
View File
@@ -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;