Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
80 changes: 80 additions & 0 deletions packages/studio-server/src/routes/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}');
});
});
109 changes: 93 additions & 16 deletions packages/studio-server/src/routes/preview.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -179,6 +180,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,
Expand Down Expand Up @@ -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) });
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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] ?? "",
Expand All @@ -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) });
Expand All @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions packages/studio-server/src/routes/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
16 changes: 16 additions & 0 deletions packages/studio-server/src/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
};
const VALID_FORMATS = new Set(["mp4", "webm", "mov"]);
const FORMAT_EXT: Record<string, string> = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
Expand Down Expand Up @@ -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<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);
Expand All @@ -112,6 +125,7 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void
jobId,
outputResolution,
composition,
variables,
distinctId:
typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : undefined,
});
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/studio-server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading