feat(studio-server): preview variable injection + render variables forwarding

Fourth PR of the template-variables Studio stack — the HTTP plumbing.

- preview routes (/preview and /preview/comp/*) accept
  ?variables=<url-encoded json> and inject
  `window.__hfVariables = {...}` into <head>, before the runtime and any
  composition script — the exact global the engine sets via
  evaluateOnNewDocument at render time, so preview-with-values cannot
  diverge from render output. Values are escaped against </script>
  breakout, malformed payloads 400 instead of silently previewing
  defaults, and the ETag is salted with a hash of the payload so cached
  previews revalidate when values change.
- POST /projects/:id/render accepts variables ({variableId: value}) and
  forwards them through StudioApiAdapter.startRender into the producer's
  RenderConfig.variables — the same channel `hyperframes render
  --variables` uses. Wired in both adapters (CLI embedded server + vite
  dev adapter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James
2026-07-09 13:31:03 -07:00
co-authored by Claude Fable 5
parent 91831f3f8e
commit 2e0b884521
8 changed files with 269 additions and 16 deletions
+1
View File
@@ -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 }) => {
@@ -0,0 +1,11 @@
/**
* Shared shape check for composition-variable payloads (`?variables=` on the
* preview routes, `body.variables` on the render route) — one contract, one
* error string, so the routes can't drift.
*/
export const VARIABLES_PAYLOAD_ERROR = "variables must be a JSON object of {variableId: value}";
export function isVariablesPayload(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -533,3 +533,83 @@ describe("hf-id surfacing in preview route", () => {
expect(disk).not.toMatch(/<li[^>]*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 <head> — before the runtime script and all body scripts.
expect(html.indexOf("data-hf-preview-variables")).toBeLessThan(html.indexOf("</head>"));
});
it("escapes </script> breakout attempts in string values", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const values = { title: "</script><script>alert(1)</script>" };
const res = await app.request(
`http://localhost/projects/demo/preview?variables=${encodeURIComponent(JSON.stringify(values))}`,
);
const html = await res.text();
const injected = /<script data-hf-preview-variables>([\s\S]*?)<\/script>/.exec(html);
expect(injected?.[1]).toContain("\\u003c/script>");
expect(injected?.[1]).not.toContain("</script>");
});
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"),
"<!doctype html><html><head></head><body><div class='clip' data-start='0' data-duration='2'>Scene</div></body></html>",
);
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"}');
});
});
+96 -16
View File
@@ -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";
@@ -13,6 +14,7 @@ import {
} from "../helpers/studioMotionRenderScript.js";
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
@@ -179,6 +181,74 @@ function injectGsapCdnFallback(html: string): string {
return GSAP_CDN_FALLBACK_SCRIPT + html;
}
/**
* Inject preview variable overrides: `?variables=<json>` 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, unknown>): string {
// <-escape prevents a string value containing "</script>" from
// breaking out of the injected tag.
const json = JSON.stringify(values).replace(/</g, "\\u003c");
const tag = `<script data-hf-preview-variables>window.__hfVariables=${json};</script>`;
// Insert as early as possible without ever landing before the doctype —
// content before <!doctype> flips the document into quirks mode, so the
// fallback chain is <head…> → <html…> → after the doctype → prepend.
for (const pattern of [/<head[^>]*>/i, /<html[^>]*>/i, /^\s*<!doctype[^>]*>/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<string, unknown> | 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<string, unknown> | 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 +312,13 @@ 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);
// fallow-ignore-next-line code-duplication
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 +370,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 +381,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 +420,16 @@ 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);
// fallow-ignore-next-line code-duplication
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 +442,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 +461,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)
@@ -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();
}
});
});
@@ -6,6 +6,7 @@ import type { StudioApiAdapter, RenderJobState } from "../types.js";
import { VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "@hyperframes/parsers";
import { parseFps } from "@hyperframes/core";
import { resolveWithinProject } from "../helpers/safePath.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
const VALID_RESOLUTIONS = new Set<string>(VALID_CANVAS_RESOLUTIONS);
@@ -65,6 +66,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<string, unknown>;
};
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
@@ -93,6 +97,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<string, unknown> | 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 +126,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
jobId,
outputResolution,
composition,
variables,
distinctId:
typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : undefined,
});
@@ -174,6 +189,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 +210,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);
+6
View File
@@ -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<string, unknown>;
/**
* Telemetry id of the browser user who triggered the render. Lets the
* adapter attribute the server-emitted render_complete/render_error to
+2
View File
@@ -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<string, unknown>;
}) => 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;