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}"`,