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;