Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
116 changes: 116 additions & 0 deletions packages/core/src/studio-api/routes/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => "<!doctype html><html><head></head><body>Preview</body></html>",
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);

const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();

expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});

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"),
`<template><section data-composition-id="scene" data-width="1280" data-height="720"></section></template>`,
);
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
transformPreviewHtml: async ({ html, activeCompositionPath }) =>
html.replace(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);

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('<meta name="preview-path" content="compositions/scene.html">');
});

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(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);

const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();

expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});

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(
"</head>",
`<meta name="preview-path" content="${activeCompositionPath}"></head>`,
),
}),
);

const response = await app.request("http://localhost/projects/demo/preview");
const html = await response.text();

expect(response.status).toBe(200);
expect(html).toContain('<meta name="preview-path" content="index.html">');
});

it("falls back to original HTML when transformPreviewHtml throws", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(
app,
createAdapter(projectDir, {
bundle: async () => "<!doctype html><html><head></head><body>Preview</body></html>",
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");
Expand Down
29 changes: 27 additions & 2 deletions packages/core/src/studio-api/routes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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",
Expand Down Expand Up @@ -167,14 +186,19 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
}

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",
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/studio-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | string;

/** Directory where render output files are stored. */
rendersDir(project: ResolvedProject): string;

Expand Down
5 changes: 5 additions & 0 deletions packages/studio/vite.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading