Skip to content

Commit 86fa10c

Browse files
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.
1 parent c6913aa commit 86fa10c

8 files changed

Lines changed: 161 additions & 18 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import * as fs from "node:fs";
3+
import * as path from "node:path";
4+
import { createStudioServer, type StudioServer } from "./studioServer.js";
5+
import { cleanupStudioServerRoot, makeStudioServerRoot } from "./studioServerTestFixture.js";
6+
7+
const COMPOSITION = `<!doctype html>
8+
<html><head><style>@font-face{font-family:Brand;src:url(assets/brand.ttf)}</style></head>
9+
<body><div id="root" data-composition-id="main" data-width="320" data-height="180">
10+
<img id="pic" src="assets/pic.png" data-start="0" data-duration="1">
11+
<img id="again" src="assets/pic.png" data-start="1" data-duration="1">
12+
<img id="responsive" src="assets/pic.png" srcset="assets/pic.png 1x, assets/pic@2x.png 2x" data-start="0" data-duration="1">
13+
<video id="clip" src="assets/clip.mp4" poster="assets/pic.png" muted data-start="0" data-duration="1"></video>
14+
<div id="scene-host" data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="1"></div>
15+
</div></body></html>`;
16+
17+
const SCENE = `<template id="scene-template">
18+
<div data-composition-id="scene" data-width="320" data-height="180">
19+
<img id="nested" src="../assets/pic@2x.png">
20+
</div>
21+
</template>`;
22+
23+
const PIC = Buffer.alloc(4096, 7);
24+
const PIC_2X = Buffer.alloc(8192, 9);
25+
26+
let root: string;
27+
let projectDir: string;
28+
let server: StudioServer;
29+
30+
beforeEach(() => {
31+
({ root, projectDir } = makeStudioServerRoot("hf-preview-assets-"));
32+
fs.mkdirSync(path.join(projectDir, "assets"));
33+
fs.mkdirSync(path.join(projectDir, "compositions"));
34+
fs.writeFileSync(path.join(projectDir, "index.html"), COMPOSITION);
35+
fs.writeFileSync(path.join(projectDir, "compositions", "scene.html"), SCENE);
36+
fs.writeFileSync(path.join(projectDir, "assets", "pic.png"), PIC);
37+
fs.writeFileSync(path.join(projectDir, "assets", "pic@2x.png"), PIC_2X);
38+
fs.writeFileSync(path.join(projectDir, "assets", "brand.ttf"), Buffer.alloc(1024, 3));
39+
fs.writeFileSync(path.join(projectDir, "assets", "clip.mp4"), Buffer.alloc(1024, 5));
40+
server = createStudioServer({ projectDir, projectName: "film" });
41+
});
42+
43+
afterEach(() => {
44+
cleanupStudioServerRoot(server, root);
45+
});
46+
47+
async function fetchAsset(url: string): Promise<Buffer> {
48+
const response = await server.app.request(`/api/projects/film/preview/${url}`);
49+
expect(response.status).toBe(200);
50+
return Buffer.from(await response.arrayBuffer());
51+
}
52+
53+
describe("studio preview document", () => {
54+
it("keeps every asset URL instead of inlining it as base64", async () => {
55+
const response = await server.app.request("/api/projects/film/preview");
56+
const html = await response.text();
57+
58+
expect(response.status).toBe(200);
59+
// Served by the bundler, not the raw-HTML fallback, so the assertions below mean something.
60+
expect(html).toContain('data-hyperframes-preview-runtime="1" src="/api/runtime.js"');
61+
expect(html).not.toContain("base64,");
62+
expect(html.match(/src="assets\/pic\.png"/g)?.length).toBeGreaterThanOrEqual(3);
63+
expect(html).toContain("url(assets/brand.ttf)");
64+
expect(html).toContain('poster="assets/pic.png"');
65+
expect(html).toContain('srcset="assets/pic.png 1x, assets/pic@2x.png 2x"');
66+
// Kept URLs resolve against the preview's <base href> through the asset route.
67+
expect(html).toContain('<base href="/api/projects/film/preview/">');
68+
expect(await fetchAsset("assets/pic.png")).toEqual(PIC);
69+
const nested = html.match(/<img [^>]*id="nested" src="([^"]+)"/)?.[1];
70+
expect(nested).toBe("assets/pic@2x.png");
71+
expect(await fetchAsset(nested ?? "")).toEqual(PIC_2X);
72+
});
73+
74+
it("tags no asset a copy tool just rewrote with its old mtime", async () => {
75+
const clip = path.join(projectDir, "assets", "clip.mp4");
76+
fs.writeFileSync(clip, Buffer.alloc(1024, 6));
77+
const copied = new Date("2026-01-01T00:00:00Z");
78+
fs.utimesSync(clip, copied, copied);
79+
80+
const response = await server.app.request("/api/projects/film/preview/assets/clip.mp4");
81+
82+
expect(response.status).toBe(200);
83+
expect(response.headers.get("ETag")).toBeNull();
84+
});
85+
});

‎packages/cli/src/server/studioServer.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
affectsProjectSignature,
4242
compositionsAffectedBy,
4343
type PreviewApiAdapter,
44+
PREVIEW_BUNDLE_OPTIONS,
4445
thumbnailDeviceScaleFactor,
4546
type ResolvedProject,
4647
type RenderJobState,
@@ -428,10 +429,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
428429
// we can point it at our hot-reloadable local runtime endpoint. Inlining
429430
// ~150 KB of runtime body on every preview render would defeat browser
430431
// caching across composition edits.
431-
let html = await bundleToSingleHtml(dir, {
432-
runtime: "placeholder",
433-
inlineColorGradingLuts: false,
434-
});
432+
let html = await bundleToSingleHtml(dir, PREVIEW_BUNDLE_OPTIONS);
435433
html = html.replace(
436434
'data-hyperframes-preview-runtime="1" src=""',
437435
'data-hyperframes-preview-runtime="1" src="/api/runtime.js"',

‎packages/studio-server/src/helpers/fileVersion.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
fileContentVersion,
55
recordFileWriteReceipt,
66
resetFileWriteReceipts,
7+
settledFileTag,
78
} from "./fileVersion";
89

910
afterEach(resetFileWriteReceipts);
@@ -79,3 +80,19 @@ describe("file versions and write receipts", () => {
7980
expect(identifyFileWrite("/project/index.html", version)).toEqual(newer);
8081
});
8182
});
83+
84+
describe("settledFileTag", () => {
85+
it("tags a file only once its last change is three seconds old", () => {
86+
const now = 1_000_000;
87+
expect(settledFileTag({ ino: 7, ctimeMs: now - 2999, size: 10 }, now)).toBeNull();
88+
expect(settledFileTag({ ino: 7, ctimeMs: now - 3000, size: 10 }, now)).toBe(
89+
[7, now - 3000, 10].map((n) => n.toString(36)).join("-"),
90+
);
91+
});
92+
93+
it("gives a file replaced at the same size and change time a new tag", () => {
94+
const now = 1_000_000;
95+
const before = settledFileTag({ ino: 7, ctimeMs: now - 5000, size: 10 }, now);
96+
expect(settledFileTag({ ino: 8, ctimeMs: now - 5000, size: 10 }, now)).not.toBe(before);
97+
});
98+
});

‎packages/studio-server/src/helpers/fileVersion.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ export function fileContentVersion(content: string | Uint8Array): string {
1818
return `"sha256:${createHash("sha256").update(content).digest("hex")}"`;
1919
}
2020

21+
/** A validator from a file's inode, change time and size, or null while the change is under three
22+
* seconds old. Change time, not mtime: copy tools (`cp -p`, rsync, robocopy) set mtime back but not
23+
* ctime, which Node reads from NTFS ChangeTime on Windows. A same-size rewrite within one tick keeps
24+
* both, so only a settled file is tagged. */
25+
export function settledFileTag(
26+
stat: { ino: number; ctimeMs: number; size: number },
27+
now = Date.now(),
28+
): string | null {
29+
if (now - stat.ctimeMs < 3000) return null;
30+
return [stat.ino, stat.ctimeMs, stat.size].map((n) => n.toString(36)).join("-");
31+
}
32+
2133
export function createWriteToken(requestToken?: string): string {
2234
const token = requestToken?.trim();
2335
return token && token.length <= 200 ? token : randomUUID();

‎packages/studio-server/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ export {
2929
type FileMutationInput,
3030
} from "./helpers/applyFileMutations.js";
3131
export type { PreviewApiAdapter } from "./helpers/mediaProxyPreview.js";
32+
export { PREVIEW_BUNDLE_OPTIONS } from "./routes/preview.js";
3233
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
3334
export {
3435
consumeFileWriteReceipt,
3536
identifyFileWrite,
3637
fileContentVersion,
38+
settledFileTag,
3739
type FileWriteReceipt,
3840
} from "./helpers/fileVersion.js";
3941
export { buildSubCompositionHtml } from "./helpers/subComposition.js";

‎packages/studio-server/src/routes/preview.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@ import type { StudioApiAdapter } from "../types";
2020
const tempDirs: string[] = [];
2121

2222
afterEach(() => {
23+
vi.useRealTimers();
2324
for (const dir of tempDirs.splice(0)) {
2425
rmSync(dir, { recursive: true, force: true });
2526
}
2627
});
2728

29+
/** Moves the clock past settledFileTag's window, so a just-written asset gets an ETag. */
30+
function pastSettleWindow(): void {
31+
vi.useFakeTimers({ toFake: ["Date"] });
32+
vi.setSystemTime(Date.now() + 60_000);
33+
}
34+
2835
function createProjectDir(): string {
2936
const projectDir = mkdtempSync(join(tmpdir(), "hf-preview-test-"));
3037
tempDirs.push(projectDir);
@@ -870,6 +877,7 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
870877
it("honors If-None-Match on a repeat request with a 304, without re-invoking resolveProxy", async () => {
871878
const projectDir = createProjectDir();
872879
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
880+
pastSettleWindow();
873881
const resolveProxyMock = vi.fn(async () => {
874882
const proxyPath = join(projectDir, "proxy.mp4");
875883
writeFileSync(proxyPath, "proxy-bytes");
@@ -899,9 +907,23 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
899907
expect(resolveProxyMock).toHaveBeenCalledTimes(1);
900908
});
901909

910+
it("tags no asset written in the last moments, so a same-size rewrite cannot reuse its tag", async () => {
911+
const projectDir = createProjectDir();
912+
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
913+
const { registerPreviewRoutes: register } = await loadPreviewModule({});
914+
const app = new Hono();
915+
register(app, createAdapter(projectDir));
916+
917+
const fresh = await app.request("http://localhost/projects/demo/preview/clip.mp4");
918+
919+
expect(fresh.status).toBe(200);
920+
expect(fresh.headers.get("ETag")).toBeNull();
921+
});
922+
902923
it("counts one proxy request per resolved proxy, not per HTTP request", async () => {
903924
const projectDir = createProjectDir();
904925
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
926+
pastSettleWindow();
905927
const resolveProxyMock = vi.fn(async () => {
906928
const proxyPath = join(projectDir, "proxy.mp4");
907929
writeFileSync(proxyPath, "0123456789proxybytes");

‎packages/studio-server/src/routes/preview.ts‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
33
import { Readable } from "node:stream";
44
import { join, resolve } from "node:path";
55
import { createHash } from "node:crypto";
6-
import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
6+
import {
7+
injectScriptsIntoHtml,
8+
stripEmbeddedRuntimeScripts,
9+
type BundleOptions,
10+
} from "@hyperframes/core/compiler";
711
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
812
import type { StudioApiAdapter } from "../types.js";
913
import { resolveWithinProject } from "../helpers/safePath.js";
@@ -19,6 +23,7 @@ import {
1923
} from "../helpers/studioMotionRenderScript.js";
2024
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
2125
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
26+
import { settledFileTag } from "../helpers/fileVersion.js";
2227
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
2328
import { injectPreviewVariables } from "../helpers/previewVariables.js";
2429
import {
@@ -307,6 +312,13 @@ function resolveProjectMainHtml(
307312
return null;
308313
}
309314

315+
/** The bundler options every adapter's `bundle()` uses. This route serves project files under a
316+
* `<base href>`, so assets keep their URLs: inlined base64 multiplies the document per reference. */
317+
export const PREVIEW_BUNDLE_OPTIONS = {
318+
runtime: "placeholder",
319+
inlineAssets: false,
320+
} as const satisfies BundleOptions;
321+
310322
export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void {
311323
const previewCacheHeaders = (etag: string) => ({
312324
"Cache-Control": "private, no-cache",
@@ -550,15 +562,13 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
550562
}
551563
}
552564

553-
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}${proxyEtagSalt(proxyVariant)}"`;
565+
const tag = settledFileTag(stat);
566+
const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
554567
const cacheHeaders: Record<string, string> = isText
555568
? { "Cache-Control": "no-store" }
556-
: {
557-
"Cache-Control": "private, no-cache",
558-
ETag: etag,
559-
};
569+
: { "Cache-Control": "private, no-cache", ...(etag && { ETag: etag }) };
560570

561-
if (!isText) {
571+
if (!isText && etag) {
562572
const ifNoneMatch = c.req.header("If-None-Match");
563573
if (ifNoneMatch === etag) {
564574
return new Response(null, { status: 304, headers: cacheHeaders });

‎packages/studio/vite.adapter.ts‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import {
2121
createBackgroundRemovalJob,
2222
createProjectSignature,
2323
affectsProjectSignature,
24+
PREVIEW_BUNDLE_OPTIONS,
2425
} from "@hyperframes/studio-server";
2526
import type { RegistryItem } from "@hyperframes/core/registry";
27+
import type { BundleOptions } from "@hyperframes/core/compiler";
2628
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
2729
import { createStudioDevRenderBodyScripts } from "./vite.studioMotion";
2830
import { generateThumbnail, findSystemChrome } from "./vite.browser";
@@ -100,12 +102,7 @@ export function createViteAdapter(
100102
server: ViteDevServer,
101103
signatureCache: ProjectSignatureCache,
102104
): StudioApiAdapter {
103-
let _bundler:
104-
| ((
105-
dir: string,
106-
options?: { runtime?: "inline" | "placeholder"; inlineColorGradingLuts?: boolean },
107-
) => Promise<string>)
108-
| null = null;
105+
let _bundler: ((dir: string, options?: BundleOptions) => Promise<string>) | null = null;
109106
let _producerModuleLoader:
110107
| (() => Promise<{
111108
createRenderJob: (config: {
@@ -238,7 +235,7 @@ export function createViteAdapter(
238235
async bundle(dir: string) {
239236
const bundler = await getBundler();
240237
if (!bundler) return null;
241-
let html = await bundler(dir, { runtime: "placeholder", inlineColorGradingLuts: false });
238+
let html = await bundler(dir, PREVIEW_BUNDLE_OPTIONS);
242239
html = html.replace(
243240
'data-hyperframes-preview-runtime="1" src=""',
244241
`data-hyperframes-preview-runtime="1" src="${this.runtimeUrl}"`,

0 commit comments

Comments
 (0)