Skip to content
Merged
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 docs/concepts/data-attributes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
| `data-height` | `"1080"` | Composition height in pixels |
| `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file |
| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. HyperFrames carries these values through, but your composition script must read and apply them manually. |
| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables '<json>'` overrides these defaults at render time. |

## Element Visibility

Expand Down
40 changes: 40 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -559,9 +559,49 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--browser-gpu` / `--no-browser-gpu` | — | on locally, off in Docker | Use or opt out of host GPU acceleration for local Chrome/WebGL capture |
| `--docker` | — | off | Use Docker for [deterministic rendering](/concepts/determinism) |
| `--quiet` | — | off | Suppress verbose output |
| `--variables` | JSON object | — | Variable overrides merged over `data-composition-variables` defaults. Read via `window.__hyperframes.getVariables()` |
| `--variables-file` | path | — | Path to a JSON file with variable overrides (alternative to `--variables`) |

CRF and target bitrate default to the `--quality` preset. Use `--crf` or `--video-bitrate` for fine-grained overrides; `RenderConfig.crf` and `RenderConfig.videoBitrate` accept the same overrides programmatically.

#### Parametrized renders

Render the same composition with different content by declaring variables on the composition root and overriding them at render time:

```html index.html
<html
data-composition-id="root"
data-composition-variables='[
{"id":"title","label":"Title","type":"string","default":"Hello"},
{"id":"theme","label":"Theme","type":"enum","options":[
{"value":"light","label":"Light"},
{"value":"dark","label":"Dark"}
],"default":"light"}
]'>
<body>
<h1 id="hero" class="clip" data-start="0" data-duration="3"></h1>
<script>
const vars = window.__hyperframes.getVariables();
document.getElementById("hero").textContent = vars.title;
document.body.dataset.theme = vars.theme;
</script>
</body>
</html>
```

```bash
# Render with declared defaults (preview also uses the defaults)
npx hyperframes render --output default.mp4

# Override at render time — missing keys fall through to declared defaults
npx hyperframes render --variables '{"title":"Q4 Report","theme":"dark"}' --output q4.mp4

# Pass values from a JSON file
npx hyperframes render --variables-file ./vars.json --output out.mp4
```

`getVariables()` returns the merged result of declared defaults and any `--variables` overrides, so the same composition runs unchanged in dev preview and in production renders.

#### WebM with Transparency

Use `--format webm` to render compositions with a transparent background. This produces VP9 video with alpha channel in a WebM container — the standard format for overlayable video.
Expand Down
107 changes: 106 additions & 1 deletion packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

const producerState = vi.hoisted(() => ({
createdJobs: [] as Array<Record<string, unknown>>,
Expand Down Expand Up @@ -99,4 +99,109 @@ describe("renderLocal browser GPU config", () => {
expect(resolveBrowserGpuForCli(false, false, "hardware")).toBe(false);
expect(resolveBrowserGpuForCli(true, undefined, "hardware")).toBe(false);
});

it("forwards parsed --variables payload to createRenderJob", async () => {
const { renderLocal } = await import("./render.js");
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: false,
hdrMode: "auto",
quiet: true,
variables: { title: "Hello", count: 3 },
});

expect(producerState.createdJobs[0]?.variables).toEqual({ title: "Hello", count: 3 });
});

it("omits variables from createRenderJob when not provided", async () => {
const { renderLocal } = await import("./render.js");
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: false,
hdrMode: "auto",
quiet: true,
});

expect(producerState.createdJobs[0]?.variables).toBeUndefined();
});
});

describe("parseVariablesArg", () => {
let parseVariablesArg: typeof import("./render.js").parseVariablesArg;

beforeAll(async () => {
({ parseVariablesArg } = await import("./render.js"));
});

function expectErr<T extends { kind: string }>(
result: import("./render.js").VariablesParseResult,
): T {
if (result.ok) throw new Error(`expected error, got ${JSON.stringify(result.value)}`);
return result.error as T;
}

it("returns undefined when neither flag is set", () => {
expect(parseVariablesArg(undefined, undefined)).toEqual({ ok: true, value: undefined });
});

it("parses inline JSON object", () => {
expect(parseVariablesArg('{"title":"Hello","n":3}', undefined)).toEqual({
ok: true,
value: { title: "Hello", n: 3 },
});
});

it("parses file JSON via injected reader", () => {
const fakeReader = (path: string) => {
if (path === "vars.json") return '{"theme":"dark"}';
throw new Error("unexpected path");
};
expect(parseVariablesArg(undefined, "vars.json", fakeReader)).toEqual({
ok: true,
value: { theme: "dark" },
});
});

it("rejects when both flags are set", () => {
const err = expectErr(parseVariablesArg('{"a":1}', "vars.json"));
expect(err).toEqual({ kind: "conflict" });
});

it("rejects unparseable JSON with a source-aware kind", () => {
expect(expectErr(parseVariablesArg("{not json", undefined))).toMatchObject({
kind: "parse-error",
source: "inline",
});
expect(expectErr(parseVariablesArg(undefined, "x", () => "{not json"))).toMatchObject({
kind: "parse-error",
source: "file",
});
});

it("rejects non-object payloads (array, string, null, number)", () => {
for (const payload of ["[1,2]", '"hello"', "null", "42"]) {
expect(expectErr(parseVariablesArg(payload, undefined))).toEqual({ kind: "shape-error" });
}
});

it("surfaces filesystem errors from --variables-file", () => {
const err = expectErr<{
kind: "read-error";
path: string;
cause: string;
}>(
parseVariablesArg(undefined, "missing.json", () => {
throw new Error("ENOENT: no such file");
}),
);
expect(err.kind).toBe("read-error");
expect(err.path).toBe("missing.json");
expect(err.cause).toMatch(/ENOENT/);
});
});
137 changes: 137 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export const examples: Example[] = [
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
["Opt out of browser GPU render", "hyperframes render --no-browser-gpu --output cpu.mp4"],
["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"],
[
"Override composition variables (parametrized render)",
'hyperframes render --variables \'{"title":"Q4 Report","theme":"dark"}\' --output q4.mp4',
],
[
"Variables from a JSON file",
"hyperframes render --variables-file ./vars.json --output out.mp4",
],
];
import { cpus, freemem, tmpdir } from "node:os";
import { resolve, dirname, join, basename } from "node:path";
Expand Down Expand Up @@ -124,6 +132,16 @@ export default defineCommand({
type: "string",
description: "Max concurrent renders when using the producer server (1-10). Default: 2.",
},
variables: {
type: "string",
description:
'JSON object of variable values, merged over the composition\'s data-composition-variables defaults. Example: --variables \'{"title":"Hello"}\'. Read inside the composition via window.__hyperframes.getVariables().',
},
"variables-file": {
type: "string",
description:
"Path to a JSON file with variable values (alternative to --variables). The file must contain a single JSON object.",
},
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
Expand Down Expand Up @@ -328,6 +346,9 @@ export default defineCommand({
process.exit(1);
}

// ── Resolve --variables / --variables-file ──────────────────────────
const variables = resolveVariablesArg(args.variables, args["variables-file"]);

// ── Render ────────────────────────────────────────────────────────────
if (useDocker) {
await renderDocker(project.dir, outputPath, {
Expand All @@ -341,6 +362,7 @@ export default defineCommand({
crf,
videoBitrate,
quiet,
variables,
});
} else {
await renderLocal(project.dir, outputPath, {
Expand All @@ -355,6 +377,7 @@ export default defineCommand({
videoBitrate,
quiet,
browserPath,
variables,
});
}
},
Expand All @@ -372,6 +395,118 @@ interface RenderOptions {
videoBitrate?: string;
quiet: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
}

export type VariablesParseError =
| { kind: "conflict" }
| { kind: "read-error"; path: string; cause: string }
| { kind: "parse-error"; source: "inline" | "file"; cause: string }
| { kind: "shape-error" };

export type VariablesParseResult =
| { ok: true; value: Record<string, unknown> | undefined }
| { ok: false; error: VariablesParseError };

/**
* Pure parser for `--variables` / `--variables-file` flag pair. Splits out
* from `resolveVariablesArg` so validation paths are unit-testable without
* triggering `process.exit`. Reports failures via a structured `kind`
* discriminant so the side-effecting wrapper owns all UI strings.
*/
export function parseVariablesArg(
inline: string | undefined,
filePath: string | undefined,
readFile: (path: string) => string = (p) => readFileSync(resolve(p), "utf8"),
): VariablesParseResult {
if (inline != null && filePath != null) {
return { ok: false, error: { kind: "conflict" } };
}
let raw: string | undefined;
let source: "inline" | "file" | undefined;
if (inline != null) {
raw = inline;
source = "inline";
} else if (filePath != null) {
try {
raw = readFile(filePath);
source = "file";
} catch (error: unknown) {
return {
ok: false,
error: {
kind: "read-error",
path: filePath,
cause: error instanceof Error ? error.message : String(error),
},
};
}
}
if (raw == null) return { ok: true, value: undefined };

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error: unknown) {
return {
ok: false,
error: {
kind: "parse-error",
source: source ?? "inline",
cause: error instanceof Error ? error.message : String(error),
},
};
}
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: { kind: "shape-error" } };
}
return { ok: true, value: parsed as Record<string, unknown> };
}

function variablesErrorMessage(error: VariablesParseError): { title: string; message: string } {
switch (error.kind) {
case "conflict":
return {
title: "Conflicting variables flags",
message: "Use either --variables or --variables-file, not both.",
};
case "read-error":
return {
title: "Could not read --variables-file",
message: `${error.path}: ${error.cause}`,
};
case "parse-error":
return {
title:
error.source === "file"
? "Invalid JSON in --variables-file"
: "Invalid JSON in --variables",
message: error.cause,
};
case "shape-error":
return {
title: "Invalid variables payload",
message: 'Variables must be a JSON object (e.g. {"title":"Hello"}).',
};
}
}

/**
* Resolve `--variables` / `--variables-file` into a plain object, or
* `undefined` when neither flag is set. Exits the process with a friendly
* error box on any validation failure.
*/
export function resolveVariablesArg(
inline: string | undefined,
filePath: string | undefined,
): Record<string, unknown> | undefined {
const result = parseVariablesArg(inline, filePath);
if (!result.ok) {
const { title, message } = variablesErrorMessage(result.error);
errorBox(title, message);
process.exit(1);
}
return result.value;
}

export function resolveBrowserGpuForCli(
Expand Down Expand Up @@ -507,6 +642,7 @@ async function renderDocker(
crf: options.crf,
videoBitrate: options.videoBitrate,
quiet: options.quiet,
variables: options.variables,
},
});

Expand Down Expand Up @@ -575,6 +711,7 @@ export async function renderLocal(
hdrMode: options.hdrMode,
crf: options.crf,
videoBitrate: options.videoBitrate,
variables: options.variables,
});

const onProgress = options.quiet
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,27 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("10M");
expect(args).not.toContain("--crf");
});

it("forwards --variables JSON to the container when set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, variables: { title: "Hello", n: 3 } },
});
const idx = args.indexOf("--variables");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('{"title":"Hello","n":3}');
});

it("omits --variables when none provided", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--variables");
});

it("omits --variables when payload is empty", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, variables: {} },
});
expect(args).not.toContain("--variables");
});
});
Loading
Loading