From 0d7d38849cec2ec408244553eb3e8d8986af5453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 21:56:44 +0200 Subject: [PATCH] fix(studio): align preview fonts with render (#799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Align Studio preview font handling with final render, and harden the transform hook against failures. ## Why Preview and render use different font handling. This bug changes text width and makes text layout look different between preview and final render. ## How - Add a `transformPreviewHtml` hook in `StudioApiAdapter` that adapters can implement to post-process preview HTML before Studio augments it - Use it in both the Vite adapter and the CLI studio server to inject the same deterministic `@font-face` rules that render uses - Wrap the hook in a try/catch so a failing transform (e.g. network error during Google Fonts fetch) degrades gracefully — the preview still loads with the original HTML ## Edge cases covered | Path | Covered | |------|---------| | Bundled HTML (adapter returns string) | ✓ | | Bundle returns null → reads index.html from disk | ✓ | | Bundle throws → catch-block fallback reads index.html | ✓ | | Sub-composition preview | ✓ | | Transform hook throws → graceful fallback to original HTML | ✓ | ## Test plan - [x] Unit tests added for all five paths above - [x] Manual testing performed Closes #797 --- packages/cli/src/server/studioServer.ts | 6 + .../src/studio-api/routes/preview.test.ts | 116 ++++++++++++++++++ .../core/src/studio-api/routes/preview.ts | 29 ++++- packages/core/src/studio-api/types.ts | 10 ++ packages/studio/vite.adapter.ts | 5 + 5 files changed, 164 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 5666ca379..b58207112 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -209,6 +209,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { } }, + async transformPreviewHtml({ html }) { + const { injectDeterministicFontFaces } = + await import("../../../producer/src/services/deterministicFonts.js"); + return injectDeterministicFontFaces(html); + }, + getProjectSignature(dir: string): string { if (resolve(dir) !== resolve(projectDir)) return createProjectSignature(dir); cachedProjectSignature ??= createProjectSignature(projectDir); diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/core/src/studio-api/routes/preview.test.ts index 1c5b1c952..54cd0a6c4 100644 --- a/packages/core/src/studio-api/routes/preview.test.ts +++ b/packages/core/src/studio-api/routes/preview.test.ts @@ -143,6 +143,122 @@ describe("registerPreviewRoutes", () => { expect(html).toContain("compositions/scene.html"); }); + it("applies adapter preview transforms to bundled root previews", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes( + app, + createAdapter(projectDir, { + bundle: async () => "Preview", + transformPreviewHtml: async ({ html, activeCompositionPath }) => + html.replace( + "", + ``, + ), + }), + ); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain(''); + }); + + it("applies adapter preview transforms to sub-composition previews", async () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, "compositions"), { recursive: true }); + writeFileSync( + join(projectDir, "compositions/scene.html"), + ``, + ); + const app = new Hono(); + registerPreviewRoutes( + app, + createAdapter(projectDir, { + transformPreviewHtml: async ({ html, activeCompositionPath }) => + html.replace( + "", + ``, + ), + }), + ); + + const response = await app.request( + "http://localhost/projects/demo/preview/comp/compositions/scene.html", + ); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain(''); + }); + + it("applies adapter preview transforms when bundle() returns null (reads from disk)", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes( + app, + createAdapter(projectDir, { + // bundle: async () => null <-- default; falls back to reading index.html from disk + transformPreviewHtml: async ({ html, activeCompositionPath }) => + html.replace( + "", + ``, + ), + }), + ); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain(''); + }); + + it("applies adapter preview transforms in the bundle error fallback path", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes( + app, + createAdapter(projectDir, { + bundle: async () => { + throw new Error("bundler unavailable"); + }, + transformPreviewHtml: async ({ html, activeCompositionPath }) => + html.replace( + "", + ``, + ), + }), + ); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain(''); + }); + + it("falls back to original HTML when transformPreviewHtml throws", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes( + app, + createAdapter(projectDir, { + bundle: async () => "Preview", + transformPreviewHtml: async () => { + throw new Error("transform failed"); + }, + }), + ); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain("Preview"); + }); + it("uses the adapter project signature when available", async () => { const projectDir = createProjectDir(); const getProjectSignature = vi.fn(() => "cached-signature"); diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index 447d080f1..a8cf5293d 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -124,6 +124,25 @@ function injectStudioPreviewAugmentations( ); } +async function transformPreviewHtml( + html: string, + adapter: StudioApiAdapter, + project: { id: string; dir: string; title?: string; sessionId?: string }, + activeCompositionPath: string, +): Promise { + if (!adapter.transformPreviewHtml) return html; + try { + return await adapter.transformPreviewHtml({ + html, + project, + activeCompositionPath, + }); + } catch (err) { + console.warn("[Studio] preview transform failed, using original HTML:", err); + return html; + } +} + export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void { const previewCacheHeaders = (etag: string) => ({ "Cache-Control": "private, no-cache", @@ -167,14 +186,19 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi bundled = bundled.replace(//i, ``); } - bundled = injectStudioPreviewAugmentations(bundled, adapter, project.dir, "index.html"); + bundled = injectStudioPreviewAugmentations( + await transformPreviewHtml(bundled, adapter, project, "index.html"), + adapter, + project.dir, + "index.html", + ); return c.html(bundled, 200, previewCacheHeaders(etag)); } catch { const file = resolve(project.dir, "index.html"); if (existsSync(file)) { return c.html( injectStudioPreviewAugmentations( - readFileSync(file, "utf-8"), + await transformPreviewHtml(readFileSync(file, "utf-8"), adapter, project, "index.html"), adapter, project.dir, "index.html", @@ -214,6 +238,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi const baseHref = `/api/projects/${project.id}/preview/`; let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref); if (!html) return c.text("not found", 404); + html = await transformPreviewHtml(html, adapter, project, compPath); return c.html( injectStudioPreviewAugmentations(html, adapter, project.dir, compPath), 200, diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts index 0f66a5764..40e1c4460 100644 --- a/packages/core/src/studio-api/types.ts +++ b/packages/core/src/studio-api/types.ts @@ -52,6 +52,16 @@ export interface StudioApiAdapter { /** URL to the hyperframe runtime JS (injected into preview HTML). */ runtimeUrl: string; + /** + * Optional: post-process preview HTML before Studio augments it. + * Useful when preview must mirror render-time compilation steps. + */ + transformPreviewHtml?: (opts: { + html: string; + project: ResolvedProject; + activeCompositionPath: string; + }) => Promise | string; + /** Directory where render output files are stored. */ rendersDir(project: ResolvedProject): string; diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index 7babcf916..10820a9f5 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -154,6 +154,11 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi return html; }, + async transformPreviewHtml({ html }) { + const producer = await import("../producer/src/services/deterministicFonts.js"); + return producer.injectDeterministicFontFaces(html); + }, + getProjectSignature(projectDir: string): string { const cacheKey = resolve(projectDir); const cached = projectSignatureCache.get(cacheKey);