diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index d6d839a06..09755a29b 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -413,6 +413,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { outputResolution: opts.outputResolution, ...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}), ...(opts.composition ? { entryFile: opts.composition } : {}), + ...(opts.variables ? { variables: opts.variables } : {}), }); renderJob = job; const onProgress = (j: { progress: number; currentStage?: string }) => { diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 6eb83390e..ba8429e57 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -533,3 +533,83 @@ describe("hf-id surfacing in preview route", () => { expect(disk).not.toMatch(/]*data-hf-id/); // clone-source untouched }); }); + +describe("preview ?variables= injection", () => { + it("injects window.__hfVariables before composition scripts in the main preview", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const values = { title: "Custom", count: 5 }; + const res = await app.request( + `http://localhost/projects/demo/preview?variables=${encodeURIComponent(JSON.stringify(values))}`, + ); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("data-hf-preview-variables"); + expect(html).toContain('window.__hfVariables={"title":"Custom","count":5}'); + // Injected in — before the runtime script and all body scripts. + expect(html.indexOf("data-hf-preview-variables")).toBeLessThan(html.indexOf("")); + }); + + it("escapes breakout attempts in string values", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const values = { title: "" }; + const res = await app.request( + `http://localhost/projects/demo/preview?variables=${encodeURIComponent(JSON.stringify(values))}`, + ); + const html = await res.text(); + const injected = /"); + }); + + it("returns 400 for invalid JSON and non-object payloads", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const bad = await app.request("http://localhost/projects/demo/preview?variables=%7Bnope"); + expect(bad.status).toBe(400); + const arr = await app.request( + `http://localhost/projects/demo/preview?variables=${encodeURIComponent("[1,2]")}`, + ); + expect(arr.status).toBe(400); + }); + + it("salts the ETag so cached previews revalidate when values change", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const plain = await app.request("http://localhost/projects/demo/preview"); + const withVars = await app.request( + `http://localhost/projects/demo/preview?variables=${encodeURIComponent('{"a":1}')}`, + ); + const otherVars = await app.request( + `http://localhost/projects/demo/preview?variables=${encodeURIComponent('{"a":2}')}`, + ); + const etags = [plain, withVars, otherVars].map((r) => r.headers.get("ETag")); + expect(new Set(etags).size).toBe(3); + }); + + it("injects variables into sub-composition previews", async () => { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "scene.html"), + "
Scene
", + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const res = await app.request( + `http://localhost/projects/demo/preview/comp/scene.html?variables=${encodeURIComponent('{"accent":"#f00"}')}`, + ); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('window.__hfVariables={"accent":"#f00"}'); + }); +}); diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index 4a4a5f488..c37c3bda5 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -1,6 +1,7 @@ import type { Hono } from "hono"; import { existsSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; +import { createHash } from "node:crypto"; import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler"; import type { StudioApiAdapter } from "../types.js"; import { resolveWithinProject } from "../helpers/safePath.js"; @@ -179,6 +180,74 @@ function injectGsapCdnFallback(html: string): string { return GSAP_CDN_FALLBACK_SCRIPT + html; } +/** + * Inject preview variable overrides: `?variables=` becomes + * `window.__hfVariables` set before any composition script runs — the exact + * global the engine sets via evaluateOnNewDocument at render time + * (engine/src/services/frameCapture.ts), so preview-with-values cannot + * diverge from render behavior. The runtime's getVariables() merges these + * overrides over the declared defaults. + */ +function injectPreviewVariables(html: string, values: Record): string { + // <-escape prevents a string value containing "" from + // breaking out of the injected tag. + const json = JSON.stringify(values).replace(/window.__hfVariables=${json};`; + // Insert as early as possible without ever landing before the doctype — + // content before flips the document into quirks mode, so the + // fallback chain is → after the doctype → prepend. + for (const pattern of [/]*>/i, /]*>/i, /^\s*]*>/i]) { + const match = pattern.exec(html); + if (match) { + const at = match.index + match[0].length; + return html.slice(0, at) + tag + html.slice(at); + } + } + return tag + html; +} + +/** + * Parse the `?variables=` query param. Absent/empty → null (no injection). + * Invalid JSON or a non-object payload is a caller error — surfaced as a 400 + * by the routes rather than silently previewing with defaults. + */ +function parsePreviewVariablesParam( + raw: string | undefined, +): { ok: true; values: Record | null } | { ok: false; error: string } { + if (raw === undefined || raw === "") return { ok: true, values: null }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, error: "variables must be valid JSON" }; + } + if (!isVariablesPayload(parsed)) { + return { ok: false, error: VARIABLES_PAYLOAD_ERROR }; + } + return { ok: true, values: parsed }; +} + +/** ETag salt so cached previews revalidate when the variable values change. */ +function variablesEtagSalt(raw: string | undefined): string { + if (!raw) return ""; + return `:vars:${createHash("sha1").update(raw).digest("hex").slice(0, 12)}`; +} + +/** + * Read + parse `?variables=` for a preview route. `error` present → the + * route should 400; otherwise `values` is the override object (or null when + * the param is absent) and `raw` feeds the ETag salt. + */ +function previewVariablesFromRequest( + rawVariables: string | undefined, +): + | { error: string } + | { error?: undefined; raw: string | undefined; values: Record | null } { + const parse = parsePreviewVariablesParam(rawVariables); + if (!parse.ok) return { error: parse.error }; + return { raw: rawVariables, values: parse.values }; +} + function injectStudioPreviewAugmentations( html: string, adapter: StudioApiAdapter, @@ -242,8 +311,12 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); + const vars = previewVariablesFromRequest(c.req.query("variables")); + if (vars.error !== undefined) return c.json({ error: vars.error }, 400); + const previewVariables = vars.values; + const signature = resolveProjectSignature(adapter, project.dir); - const etag = `"preview:${signature}"`; + const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`; const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch === etag) { return new Response(null, { status: 304, headers: previewCacheHeaders(etag) }); @@ -295,6 +368,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi project.dir, mainCompositionPath, ); + if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables); return c.html(bundled, 200, previewCacheHeaders(etag)); } catch { // Re-read disk on bundle failure so we serve the latest file content, @@ -305,16 +379,16 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi join(project.dir, fallback.compositionPath), fallback.html, ); - return c.html( - injectStudioPreviewAugmentations( - await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath), - adapter, - project.dir, - fallback.compositionPath, - ), - 200, - previewCacheHeaders(etag), + let fallbackAugmented = injectStudioPreviewAugmentations( + await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath), + adapter, + project.dir, + fallback.compositionPath, ); + if (previewVariables) { + fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables); + } + return c.html(fallbackAugmented, 200, previewCacheHeaders(etag)); } return c.text("not found", 404); } @@ -344,10 +418,15 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi } // Sub-composition preview + // fallow-ignore-next-line complexity api.get("/projects/:id/preview/comp/*", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); if (!project) return c.json({ error: "not found" }, 404); + const vars = previewVariablesFromRequest(c.req.query("variables")); + if (vars.error !== undefined) return c.json({ error: vars.error }, 400); + const previewVariables = vars.values; + const signature = resolveProjectSignature(adapter, project.dir); const compPath = decodeURIComponent( c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? "", @@ -360,7 +439,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi // "v2" salts the etag for the hf-id-pinning change below: a client holding // a pre-pin cached response (preview-only ids, unstamped disk file) must // not revalidate to a 304 that skips the pin. - const etag = `"comp:v2:${compPath}:${signature}"`; + const etag = `"comp:v2:${compPath}:${signature}${variablesEtagSalt(vars.raw)}"`; const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch === etag) { return new Response(null, { status: 304, headers: previewCacheHeaders(etag) }); @@ -379,11 +458,9 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi ); if (!html) return c.text("not found", 404); html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath)); - return c.html( - injectStudioPreviewAugmentations(html, adapter, project.dir, compPath), - 200, - previewCacheHeaders(etag), - ); + html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath); + if (previewVariables) html = injectPreviewVariables(html, previewVariables); + return c.html(html, 200, previewCacheHeaders(etag)); }); // Static asset serving (with range request support for audio/video seeking) diff --git a/packages/studio-server/src/routes/render.test.ts b/packages/studio-server/src/routes/render.test.ts index e3d4896a8..2b44d4e3d 100644 --- a/packages/studio-server/src/routes/render.test.ts +++ b/packages/studio-server/src/routes/render.test.ts @@ -563,3 +563,59 @@ describe("POST /projects/:id/render — telemetryDistinctId forwarding", () => { } }); }); + +describe("POST /projects/:id/render — variables forwarding", () => { + it("forwards a variables object to the adapter", async () => { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + const res = await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + format: "mp4", + variables: { title: "Custom", count: 5, dark: true }, + }), + }); + expect(res.status).toBe(200); + expect(spy).toHaveBeenCalledOnce(); + expect(spy.mock.calls[0][0].variables).toEqual({ title: "Custom", count: 5, dark: true }); + } finally { + cleanup(); + } + }); + + it("omits variables from adapter opts when not provided", async () => { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + const res = await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ format: "mp4" }), + }); + expect(res.status).toBe(200); + expect(spy.mock.calls[0][0].variables).toBeUndefined(); + } finally { + cleanup(); + } + }); + + it("rejects non-object variables payloads with 400 (no silent drop)", async () => { + const spy = vi.fn(); + const { app, cleanup } = buildApp(spy); + try { + for (const variables of [["a"], "str", 42, null]) { + const res = await app.request("http://localhost/projects/demo/render", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ format: "mp4", variables }), + }); + expect(res.status).toBe(400); + } + expect(spy).not.toHaveBeenCalled(); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/studio-server/src/routes/render.ts b/packages/studio-server/src/routes/render.ts index 369fbe905..b5c8254de 100644 --- a/packages/studio-server/src/routes/render.ts +++ b/packages/studio-server/src/routes/render.ts @@ -65,6 +65,9 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void // Browser telemetry id, so the server-emitted render outcome is // attributed to the user who triggered the render (joinable funnel). telemetryDistinctId?: string; + // Composition-variable overrides ({variableId: value}), injected as + // window.__hfVariables — same channel as `hyperframes render --variables`. + variables?: Record; }; const VALID_FORMATS = new Set(["mp4", "webm", "mov"]); const FORMAT_EXT: Record = { mp4: ".mp4", webm: ".webm", mov: ".mov" }; @@ -93,6 +96,16 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void composition = body.composition; } + // Unlike fps/quality (lenient with safe fallbacks), a malformed variables + // payload means the user's values would be silently dropped — fail loudly. + let variables: Record | undefined; + if (body.variables !== undefined) { + if (!isVariablesPayload(body.variables)) { + return c.json({ error: VARIABLES_PAYLOAD_ERROR }, 400); + } + variables = body.variables; + } + // fallow-ignore-next-line code-duplication const now = new Date(); const datePart = now.toISOString().slice(0, 10); @@ -112,6 +125,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void jobId, outputResolution, composition, + variables, distinctId: typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : undefined, }); @@ -174,6 +188,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void } // Serve render inline (for in-browser playback — opens in a new tab) + // fallow-ignore-next-line code-duplication api.get("/render/:jobId/view", (c) => { const { jobId } = c.req.param(); const job = renderJobs.get(jobId); @@ -194,6 +209,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void }); // Download render + // fallow-ignore-next-line code-duplication api.get("/render/:jobId/download", (c) => { const { jobId } = c.req.param(); const job = renderJobs.get(jobId); diff --git a/packages/studio-server/src/types.ts b/packages/studio-server/src/types.ts index 79a313dc0..f9223ece0 100644 --- a/packages/studio-server/src/types.ts +++ b/packages/studio-server/src/types.ts @@ -151,6 +151,12 @@ export interface StudioApiAdapter { outputResolution?: CanvasResolution; /** Entry file relative to projectDir (e.g. "compositions/intro.html"). Defaults to index.html. */ composition?: string; + /** + * Composition-variable overrides ({variableId: value}), forwarded to the + * producer's RenderConfig.variables and injected as window.__hfVariables — + * the same channel `hyperframes render --variables` uses. + */ + variables?: Record; /** * Telemetry id of the browser user who triggered the render. Lets the * adapter attribute the server-emitted render_complete/render_error to diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index 2f313e5ce..7bf662d91 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -48,6 +48,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi format: string; renderBodyScripts?: string[]; outputResolution?: "landscape" | "portrait" | "landscape-4k" | "portrait-4k"; + variables?: Record; }) => unknown; executeRenderJob: ( job: unknown, @@ -246,6 +247,7 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi ...(renderBodyScripts.length > 0 ? { renderBodyScripts } : {}), outputResolution: opts.outputResolution, ...(opts.composition ? { entryFile: opts.composition } : {}), + ...(opts.variables ? { variables: opts.variables } : {}), }); const onProgress = (j: { progress: number; currentStage?: string }) => { state.progress = j.progress;