From 86fa10ced4297bfd9f4fe67b6767afae7a07fd3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 23 Sep 2026 21:08:56 -0400 Subject: [PATCH] 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. --- .../server/studioServer.previewAssets.test.ts | 85 +++++++++++++++++++ packages/cli/src/server/studioServer.ts | 6 +- .../src/helpers/fileVersion.test.ts | 17 ++++ .../studio-server/src/helpers/fileVersion.ts | 12 +++ packages/studio-server/src/index.ts | 2 + .../studio-server/src/routes/preview.test.ts | 22 +++++ packages/studio-server/src/routes/preview.ts | 24 ++++-- packages/studio/vite.adapter.ts | 11 +-- 8 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/server/studioServer.previewAssets.test.ts diff --git a/packages/cli/src/server/studioServer.previewAssets.test.ts b/packages/cli/src/server/studioServer.previewAssets.test.ts new file mode 100644 index 0000000000..d74c0d6dd0 --- /dev/null +++ b/packages/cli/src/server/studioServer.previewAssets.test.ts @@ -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 = ` + +
+ + + + +
+
`; + +const SCENE = ``; + +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 { + 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 through the asset route. + expect(html).toContain(''); + expect(await fetchAsset("assets/pic.png")).toEqual(PIC); + const nested = html.match(/]*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(); + }); +}); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 4ac7c8dec1..78fb439709 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -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"', diff --git a/packages/studio-server/src/helpers/fileVersion.test.ts b/packages/studio-server/src/helpers/fileVersion.test.ts index 6a8c3f8f7e..aa7151b230 100644 --- a/packages/studio-server/src/helpers/fileVersion.test.ts +++ b/packages/studio-server/src/helpers/fileVersion.test.ts @@ -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); + }); +}); diff --git a/packages/studio-server/src/helpers/fileVersion.ts b/packages/studio-server/src/helpers/fileVersion.ts index 037babde4d..dbce9083e9 100644 --- a/packages/studio-server/src/helpers/fileVersion.ts +++ b/packages/studio-server/src/helpers/fileVersion.ts @@ -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(); diff --git a/packages/studio-server/src/index.ts b/packages/studio-server/src/index.ts index 47567bca87..110126c9df 100644 --- a/packages/studio-server/src/index.ts +++ b/packages/studio-server/src/index.ts @@ -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"; diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 6420b38bdd..6150dbd4fd 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -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"); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index 4ed13a4f59..e2169a5771 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -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 + * ``, 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 = 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 }); diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index 261573b42c..4a4578c779 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -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) - | null = null; + let _bundler: ((dir: string, options?: BundleOptions) => Promise) | 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}"`,