perf(studio): show a film's first frame sooner by loading preview assets by URL (#4357)

* fix(studio): serve preview assets by URL instead of inlining them as base64

The Studio preview route serves project files under a base href, but both preview adapters
called the bundler with its self-contained defaults, so every image and font was inlined
once per reference. One exported option set now owns how a preview bundle is built.

* test(studio): cover poster, srcset and sub-composition preview asset URLs

Also types the Vite adapter bundler with BundleOptions, so the shared preview options are
checked against the real option set.

* fix(studio): tag a preview asset for revalidation only once it has settled

Preview assets are now served by URL, so the asset route serves every image and video. It tagged
them by mtime and size; two same-size writes inside one filesystem tick share an mtime, and copy
tools (cp -p, rsync) set mtime back, so a browser could keep stale bytes after an edit. The route now
tags a file by inode, change time and size, and only once its last change is three seconds old; a
younger file is served without a tag and refetched.
This commit is contained in:
Miguel Ángel
2026-09-23 21:08:56 -04:00
committed by GitHub
parent c6913aabe2
commit 86fa10ced4
8 changed files with 161 additions and 18 deletions
@@ -0,0 +1,85 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import { createStudioServer, type StudioServer } from "./studioServer.js";
import { cleanupStudioServerRoot, makeStudioServerRoot } from "./studioServerTestFixture.js";
const COMPOSITION = `<!doctype html>
<html><head><style>@font-face{font-family:Brand;src:url(assets/brand.ttf)}</style></head>
<body><div id="root" data-composition-id="main" data-width="320" data-height="180">
<img id="pic" src="assets/pic.png" data-start="0" data-duration="1">
<img id="again" src="assets/pic.png" data-start="1" data-duration="1">
<img id="responsive" src="assets/pic.png" srcset="assets/pic.png 1x, assets/pic@2x.png 2x" data-start="0" data-duration="1">
<video id="clip" src="assets/clip.mp4" poster="assets/pic.png" muted data-start="0" data-duration="1"></video>
<div id="scene-host" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="1"></div>
</div></body></html>`;
const SCENE = `<template id="scene-template">
<div data-composition-id="scene" data-width="320" data-height="180">
<img id="nested" src="../assets/pic@2x.png">
</div>
</template>`;
const PIC = Buffer.alloc(4096, 7);
const PIC_2X = Buffer.alloc(8192, 9);
let root: string;
let projectDir: string;
let server: StudioServer;
beforeEach(() => {
({ root, projectDir } = makeStudioServerRoot("hf-preview-assets-"));
fs.mkdirSync(path.join(projectDir, "assets"));
fs.mkdirSync(path.join(projectDir, "compositions"));
fs.writeFileSync(path.join(projectDir, "index.html"), COMPOSITION);
fs.writeFileSync(path.join(projectDir, "compositions", "scene.html"), SCENE);
fs.writeFileSync(path.join(projectDir, "assets", "pic.png"), PIC);
fs.writeFileSync(path.join(projectDir, "assets", "pic@2x.png"), PIC_2X);
fs.writeFileSync(path.join(projectDir, "assets", "brand.ttf"), Buffer.alloc(1024, 3));
fs.writeFileSync(path.join(projectDir, "assets", "clip.mp4"), Buffer.alloc(1024, 5));
server = createStudioServer({ projectDir, projectName: "film" });
});
afterEach(() => {
cleanupStudioServerRoot(server, root);
});
async function fetchAsset(url: string): Promise<Buffer> {
const response = await server.app.request(`/api/projects/film/preview/${url}`);
expect(response.status).toBe(200);
return Buffer.from(await response.arrayBuffer());
}
describe("studio preview document", () => {
it("keeps every asset URL instead of inlining it as base64", async () => {
const response = await server.app.request("/api/projects/film/preview");
const html = await response.text();
expect(response.status).toBe(200);
// Served by the bundler, not the raw-HTML fallback, so the assertions below mean something.
expect(html).toContain('data-hyperframes-preview-runtime="1" src="/api/runtime.js"');
expect(html).not.toContain("base64,");
expect(html.match(/src="assets\/pic\.png"/g)?.length).toBeGreaterThanOrEqual(3);
expect(html).toContain("url(assets/brand.ttf)");
expect(html).toContain('poster="assets/pic.png"');
expect(html).toContain('srcset="assets/pic.png 1x, assets/pic@2x.png 2x"');
// Kept URLs resolve against the preview's <base href> through the asset route.
expect(html).toContain('<base href="/api/projects/film/preview/">');
expect(await fetchAsset("assets/pic.png")).toEqual(PIC);
const nested = html.match(/<img [^>]*id="nested" src="([^"]+)"/)?.[1];
expect(nested).toBe("assets/pic@2x.png");
expect(await fetchAsset(nested ?? "")).toEqual(PIC_2X);
});
it("tags no asset a copy tool just rewrote with its old mtime", async () => {
const clip = path.join(projectDir, "assets", "clip.mp4");
fs.writeFileSync(clip, Buffer.alloc(1024, 6));
const copied = new Date("2026-01-01T00:00:00Z");
fs.utimesSync(clip, copied, copied);
const response = await server.app.request("/api/projects/film/preview/assets/clip.mp4");
expect(response.status).toBe(200);
expect(response.headers.get("ETag")).toBeNull();
});
});
+2 -4
View File
@@ -41,6 +41,7 @@ import {
affectsProjectSignature,
compositionsAffectedBy,
type PreviewApiAdapter,
PREVIEW_BUNDLE_OPTIONS,
thumbnailDeviceScaleFactor,
type ResolvedProject,
type RenderJobState,
@@ -428,10 +429,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
// we can point it at our hot-reloadable local runtime endpoint. Inlining
// ~150 KB of runtime body on every preview render would defeat browser
// caching across composition edits.
let html = await bundleToSingleHtml(dir, {
runtime: "placeholder",
inlineColorGradingLuts: false,
});
let html = await bundleToSingleHtml(dir, PREVIEW_BUNDLE_OPTIONS);
html = html.replace(
'data-hyperframes-preview-runtime="1" src=""',
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',
@@ -4,6 +4,7 @@ import {
fileContentVersion,
recordFileWriteReceipt,
resetFileWriteReceipts,
settledFileTag,
} from "./fileVersion";
afterEach(resetFileWriteReceipts);
@@ -79,3 +80,19 @@ describe("file versions and write receipts", () => {
expect(identifyFileWrite("/project/index.html", version)).toEqual(newer);
});
});
describe("settledFileTag", () => {
it("tags a file only once its last change is three seconds old", () => {
const now = 1_000_000;
expect(settledFileTag({ ino: 7, ctimeMs: now - 2999, size: 10 }, now)).toBeNull();
expect(settledFileTag({ ino: 7, ctimeMs: now - 3000, size: 10 }, now)).toBe(
[7, now - 3000, 10].map((n) => n.toString(36)).join("-"),
);
});
it("gives a file replaced at the same size and change time a new tag", () => {
const now = 1_000_000;
const before = settledFileTag({ ino: 7, ctimeMs: now - 5000, size: 10 }, now);
expect(settledFileTag({ ino: 8, ctimeMs: now - 5000, size: 10 }, now)).not.toBe(before);
});
});
@@ -18,6 +18,18 @@ export function fileContentVersion(content: string | Uint8Array): string {
return `"sha256:${createHash("sha256").update(content).digest("hex")}"`;
}
/** A validator from a file's inode, change time and size, or null while the change is under three
* seconds old. Change time, not mtime: copy tools (`cp -p`, rsync, robocopy) set mtime back but not
* ctime, which Node reads from NTFS ChangeTime on Windows. A same-size rewrite within one tick keeps
* both, so only a settled file is tagged. */
export function settledFileTag(
stat: { ino: number; ctimeMs: number; size: number },
now = Date.now(),
): string | null {
if (now - stat.ctimeMs < 3000) return null;
return [stat.ino, stat.ctimeMs, stat.size].map((n) => n.toString(36)).join("-");
}
export function createWriteToken(requestToken?: string): string {
const token = requestToken?.trim();
return token && token.length <= 200 ? token : randomUUID();
+2
View File
@@ -29,11 +29,13 @@ export {
type FileMutationInput,
} from "./helpers/applyFileMutations.js";
export type { PreviewApiAdapter } from "./helpers/mediaProxyPreview.js";
export { PREVIEW_BUNDLE_OPTIONS } from "./routes/preview.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export {
consumeFileWriteReceipt,
identifyFileWrite,
fileContentVersion,
settledFileTag,
type FileWriteReceipt,
} from "./helpers/fileVersion.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
@@ -20,11 +20,18 @@ import type { StudioApiAdapter } from "../types";
const tempDirs: string[] = [];
afterEach(() => {
vi.useRealTimers();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
/** Moves the clock past settledFileTag's window, so a just-written asset gets an ETag. */
function pastSettleWindow(): void {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(Date.now() + 60_000);
}
function createProjectDir(): string {
const projectDir = mkdtempSync(join(tmpdir(), "hf-preview-test-"));
tempDirs.push(projectDir);
@@ -870,6 +877,7 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
it("honors If-None-Match on a repeat request with a 304, without re-invoking resolveProxy", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
pastSettleWindow();
const resolveProxyMock = vi.fn(async () => {
const proxyPath = join(projectDir, "proxy.mp4");
writeFileSync(proxyPath, "proxy-bytes");
@@ -899,9 +907,23 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
expect(resolveProxyMock).toHaveBeenCalledTimes(1);
});
it("tags no asset written in the last moments, so a same-size rewrite cannot reuse its tag", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
const { registerPreviewRoutes: register } = await loadPreviewModule({});
const app = new Hono();
register(app, createAdapter(projectDir));
const fresh = await app.request("http://localhost/projects/demo/preview/clip.mp4");
expect(fresh.status).toBe(200);
expect(fresh.headers.get("ETag")).toBeNull();
});
it("counts one proxy request per resolved proxy, not per HTTP request", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
pastSettleWindow();
const resolveProxyMock = vi.fn(async () => {
const proxyPath = join(projectDir, "proxy.mp4");
writeFileSync(proxyPath, "0123456789proxybytes");
+17 -7
View File
@@ -3,7 +3,11 @@ import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
import { Readable } from "node:stream";
import { join, resolve } from "node:path";
import { createHash } from "node:crypto";
import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
import {
injectScriptsIntoHtml,
stripEmbeddedRuntimeScripts,
type BundleOptions,
} from "@hyperframes/core/compiler";
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
import type { StudioApiAdapter } from "../types.js";
import { resolveWithinProject } from "../helpers/safePath.js";
@@ -19,6 +23,7 @@ import {
} from "../helpers/studioMotionRenderScript.js";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
import { settledFileTag } from "../helpers/fileVersion.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
import { injectPreviewVariables } from "../helpers/previewVariables.js";
import {
@@ -307,6 +312,13 @@ function resolveProjectMainHtml(
return null;
}
/** The bundler options every adapter's `bundle()` uses. This route serves project files under a
* `<base href>`, so assets keep their URLs: inlined base64 multiplies the document per reference. */
export const PREVIEW_BUNDLE_OPTIONS = {
runtime: "placeholder",
inlineAssets: false,
} as const satisfies BundleOptions;
export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void {
const previewCacheHeaders = (etag: string) => ({
"Cache-Control": "private, no-cache",
@@ -550,15 +562,13 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
}
}
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}${proxyEtagSalt(proxyVariant)}"`;
const tag = settledFileTag(stat);
const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
const cacheHeaders: Record<string, string> = isText
? { "Cache-Control": "no-store" }
: {
"Cache-Control": "private, no-cache",
ETag: etag,
};
: { "Cache-Control": "private, no-cache", ...(etag && { ETag: etag }) };
if (!isText) {
if (!isText && etag) {
const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) {
return new Response(null, { status: 304, headers: cacheHeaders });
+4 -7
View File
@@ -21,8 +21,10 @@ import {
createBackgroundRemovalJob,
createProjectSignature,
affectsProjectSignature,
PREVIEW_BUNDLE_OPTIONS,
} from "@hyperframes/studio-server";
import type { RegistryItem } from "@hyperframes/core/registry";
import type { BundleOptions } from "@hyperframes/core/compiler";
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
import { createStudioDevRenderBodyScripts } from "./vite.studioMotion";
import { generateThumbnail, findSystemChrome } from "./vite.browser";
@@ -100,12 +102,7 @@ export function createViteAdapter(
server: ViteDevServer,
signatureCache: ProjectSignatureCache,
): StudioApiAdapter {
let _bundler:
| ((
dir: string,
options?: { runtime?: "inline" | "placeholder"; inlineColorGradingLuts?: boolean },
) => Promise<string>)
| null = null;
let _bundler: ((dir: string, options?: BundleOptions) => Promise<string>) | null = null;
let _producerModuleLoader:
| (() => Promise<{
createRenderJob: (config: {
@@ -238,7 +235,7 @@ export function createViteAdapter(
async bundle(dir: string) {
const bundler = await getBundler();
if (!bundler) return null;
let html = await bundler(dir, { runtime: "placeholder", inlineColorGradingLuts: false });
let html = await bundler(dir, PREVIEW_BUNDLE_OPTIONS);
html = html.replace(
'data-hyperframes-preview-runtime="1" src=""',
`data-hyperframes-preview-runtime="1" src="${this.runtimeUrl}"`,