mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
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:
@@ -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"}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user