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