diff --git a/docs/concepts/variables.mdx b/docs/concepts/variables.mdx
index 6b9a046ec..042a56c62 100644
--- a/docs/concepts/variables.mdx
+++ b/docs/concepts/variables.mdx
@@ -193,6 +193,25 @@ npx hyperframes render --variables '{"title":"Q4 Report"}' --strict-variables --
CLI overrides apply only to the top-level composition. Sub-composition variables are controlled by `data-variable-values` on each host element.
+## Batch Renders
+
+Use `--batch` when the same composition should render once per data row:
+
+```json rows.json
+[
+ { "name": "Alice", "title": "Q4 Report" },
+ { "name": "Bob", "title": "Renewal Plan" }
+]
+```
+
+```bash Terminal
+npx hyperframes render --batch rows.json --output "renders/{name}.mp4" --strict-variables
+```
+
+Each row is treated like a `--variables` object and merged over the composition defaults. Output paths support `{key}` placeholders from the row plus `{index}`. Hyperframes validates missing placeholders, output collisions, and `--strict-variables` issues before the first row starts rendering, then writes `manifest.json` next to the outputs with one status row per render.
+
+For small compositions, `--batch-concurrency 2` can run rows in parallel. The default is `1` because each individual render already parallelizes across render workers.
+
## Layering and Precedence
Variable values are resolved by merging three sources, lowest to highest precedence:
diff --git a/docs/guides/pipeline.mdx b/docs/guides/pipeline.mdx
index 0b72c96a2..900e4f577 100644
--- a/docs/guides/pipeline.mdx
+++ b/docs/guides/pipeline.mdx
@@ -178,6 +178,8 @@ The pipeline delivers the localhost Studio URL as the handoff. Your AI agent run
npx hyperframes render --output my-video.mp4
```
+For personalized or catalog outputs, render the same validated composition with `--batch rows.json --output "renders/{name}.mp4"` and use the generated `manifest.json` as the delivery checklist.
+
**Gate:** `lint` and `validate` pass with zero errors. Snapshot frames look right. The Studio preview URL is ready to share.
## Iterating
diff --git a/docs/guides/rendering.mdx b/docs/guides/rendering.mdx
index cf7a4c127..b5643c7c9 100644
--- a/docs/guides/rendering.mdx
+++ b/docs/guides/rendering.mdx
@@ -124,6 +124,9 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, WebM
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate encoding. Cannot combine with `--crf` |
| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
| `--max-concurrent-renders` | 1-10 | 2 | Max simultaneous renders via the producer server (see [Concurrent Renders](#concurrent-renders) below) |
+| `--batch` | path | — | JSON array of variable rows (or `{ "rows": [...] }`), rendering one output per row |
+| `--batch-concurrency` | integer | 1 | Maximum batch rows to render at once |
+| `--batch-fail-fast` | — | off | Stop launching new batch rows after the first row failure |
| `--gpu` | — | off | GPU encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV) |
| `--browser-gpu` / `--no-browser-gpu` | — | on locally, off in Docker | Use or opt out of host GPU acceleration for local Chrome/WebGL capture |
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected (MP4 only). See [HDR Rendering](/guides/hdr) |
@@ -231,6 +234,25 @@ npx hyperframes render --workers 8 --output output.mp4
- Dedicated render machines or CI runners
- Docker mode on a well-provisioned host
+## Batch Rendering
+
+Batch rendering runs the same composition once per variables row:
+
+```json rows.json
+[
+ { "name": "Alice", "headline": "Welcome, Alice" },
+ { "name": "Bob", "headline": "Welcome, Bob" }
+]
+```
+
+```bash Terminal
+npx hyperframes render --batch rows.json --output "renders/{name}.mp4" --strict-variables
+```
+
+`--output` is a template. Use `{index}` or any scalar key from the row to make each path unique. Hyperframes preflights the full batch before rendering: malformed rows, missing placeholders, duplicate output paths, and strict variable mismatches fail before the first video starts. A `manifest.json` file is written next to the outputs with per-row status, output path, render time, duration when available, and error details.
+
+Rows continue after failures by default so a bad data row does not discard the rest of the batch. Add `--batch-fail-fast` to stop launching new rows after the first failure, or `--json` to stream machine-readable progress events while the manifest is updated.
+
## Concurrent Renders
When multiple render requests hit the producer server simultaneously (common with AI agents), each render spawns its own set of Chrome worker processes. Too many concurrent renders can exhaust CPU and cause failures.
diff --git a/packages/cli/src/commands/batchRender.test.ts b/packages/cli/src/commands/batchRender.test.ts
new file mode 100644
index 000000000..3953ef662
--- /dev/null
+++ b/packages/cli/src/commands/batchRender.test.ts
@@ -0,0 +1,280 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import {
+ BatchRenderInputError,
+ parseBatchRows,
+ prepareBatchRender,
+ resolveOutputTemplate,
+ runBatchRender,
+} from "./batchRender.js";
+
+let tmpDir: string;
+
+beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), "hf-local-batch-render-"));
+});
+
+afterEach(() => {
+ rmSync(tmpDir, { recursive: true, force: true });
+ vi.restoreAllMocks();
+});
+
+function writeJson(name: string, content: string): string {
+ const path = join(tmpDir, name);
+ writeFileSync(path, content, "utf8");
+ return path;
+}
+
+function writeIndex(schema = "[]"): string {
+ return writeJson(
+ "index.html",
+ `
`,
+ );
+}
+
+function expectBatchError(fn: () => unknown, title: string): BatchRenderInputError {
+ try {
+ fn();
+ } catch (error: unknown) {
+ expect(error).toBeInstanceOf(BatchRenderInputError);
+ if (error instanceof BatchRenderInputError) {
+ expect(error.title).toBe(title);
+ return error;
+ }
+ }
+ throw new Error("Expected BatchRenderInputError");
+}
+
+function eventType(value: unknown): string | undefined {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
+ return "type" in value && typeof value.type === "string" ? value.type : undefined;
+}
+
+describe("parseBatchRows", () => {
+ it("parses a JSON array of variable rows", () => {
+ expect(parseBatchRows('[{"name":"Alice"},{"name":"Bob"}]', "rows.json")).toEqual([
+ { name: "Alice" },
+ { name: "Bob" },
+ ]);
+ });
+
+ it("parses an object with a rows array", () => {
+ expect(parseBatchRows('{"rows":[{"name":"Alice"}]}', "rows.json")).toEqual([{ name: "Alice" }]);
+ });
+
+ it("rejects non-object rows", () => {
+ const error = expectBatchError(
+ () => parseBatchRows('[{"name":"Alice"},null]', "rows.json"),
+ "Invalid batch row",
+ );
+ expect(error.message).toMatch(/Row 1/);
+ });
+});
+
+describe("resolveOutputTemplate", () => {
+ it("replaces row placeholders and index", () => {
+ expect(resolveOutputTemplate("renders/{name}-{index}.mp4", { name: "Alice" }, 3)).toBe(
+ "renders/Alice-3.mp4",
+ );
+ });
+
+ it("rejects missing placeholder keys", () => {
+ const error = expectBatchError(
+ () => resolveOutputTemplate("renders/{slug}.mp4", { name: "Alice" }, 0),
+ "Invalid output template",
+ );
+ expect(error.message).toMatch(/Missing value/);
+ });
+});
+
+describe("prepareBatchRender", () => {
+ it("resolves output paths and the manifest path", () => {
+ const batchPath = writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"}]');
+ const outDir = join(tmpDir, "renders");
+ const prepared = prepareBatchRender({
+ batchPath,
+ outputTemplate: join(outDir, "{name}.mp4"),
+ indexPath: writeIndex(),
+ strictVariables: false,
+ quiet: true,
+ json: false,
+ });
+
+ expect(prepared.rows.map((row) => row.outputPath)).toEqual([
+ resolve(outDir, "Alice.mp4"),
+ resolve(outDir, "Bob.mp4"),
+ ]);
+ expect(prepared.manifestPath).toBe(resolve(outDir, "manifest.json"));
+ });
+
+ it("rejects output collisions before rendering", () => {
+ const batchPath = writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"}]');
+ const error = expectBatchError(
+ () =>
+ prepareBatchRender({
+ batchPath,
+ outputTemplate: join(tmpDir, "same.mp4"),
+ indexPath: writeIndex(),
+ strictVariables: false,
+ quiet: true,
+ json: false,
+ }),
+ "Batch output collision",
+ );
+ expect(error.message).toMatch(/Rows 0 and 1/);
+ });
+
+ it("fails strict variable validation per row", () => {
+ const batchPath = writeJson("rows.json", '[{"title":"Hello"},{"title":3}]');
+ const schema = '[{"id":"title","type":"string","label":"Title","default":"Untitled"}]';
+ const error = expectBatchError(
+ () =>
+ prepareBatchRender({
+ batchPath,
+ outputTemplate: join(tmpDir, "{index}.mp4"),
+ indexPath: writeIndex(schema),
+ strictVariables: true,
+ quiet: true,
+ json: true,
+ }),
+ "Variable validation failed",
+ );
+ expect(error.message).toMatch(/row 1/);
+ });
+
+ it("counts non-strict variable validation issues without failing", () => {
+ const batchPath = writeJson("rows.json", '[{"title":3}]');
+ const schema = '[{"id":"title","type":"string","label":"Title","default":"Untitled"}]';
+ const prepared = prepareBatchRender({
+ batchPath,
+ outputTemplate: join(tmpDir, "{index}.mp4"),
+ indexPath: writeIndex(schema),
+ strictVariables: false,
+ quiet: true,
+ json: false,
+ });
+
+ expect(prepared.variableIssueCount).toBe(1);
+ });
+});
+
+describe("runBatchRender", () => {
+ it("writes a manifest with completed rows", async () => {
+ const prepared = prepareBatchRender({
+ batchPath: writeJson("rows.json", '[{"name":"Alice"}]'),
+ outputTemplate: join(tmpDir, "renders/{name}.mp4"),
+ indexPath: writeIndex(),
+ strictVariables: false,
+ quiet: true,
+ json: false,
+ });
+
+ const manifest = await runBatchRender({
+ prepared,
+ concurrency: 1,
+ failFast: false,
+ quiet: true,
+ json: false,
+ renderOne: async () => ({ durationMs: 3000, renderTimeMs: 42 }),
+ });
+
+ expect(manifest.completed).toBe(1);
+ expect(manifest.failed).toBe(0);
+ expect(manifest.rows[0]).toMatchObject({
+ index: 0,
+ status: "completed",
+ durationMs: 3000,
+ renderTimeMs: 42,
+ error: null,
+ });
+ expect(readFileSync(prepared.manifestPath, "utf8")).toContain('"status": "completed"');
+ });
+
+ it("emits JSON progress events when json mode is enabled", async () => {
+ const prepared = prepareBatchRender({
+ batchPath: writeJson("rows.json", '[{"name":"Alice"}]'),
+ outputTemplate: join(tmpDir, "renders/{name}.mp4"),
+ indexPath: writeIndex(),
+ strictVariables: false,
+ quiet: true,
+ json: true,
+ });
+ const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
+
+ await runBatchRender({
+ prepared,
+ concurrency: 1,
+ failFast: false,
+ quiet: true,
+ json: true,
+ renderOne: async () => ({ renderTimeMs: 10 }),
+ });
+
+ const events = log.mock.calls.map((call): unknown => JSON.parse(String(call[0])));
+ expect(events.map(eventType)).toEqual([
+ "batch-row-start",
+ "batch-row-complete",
+ "batch-complete",
+ ]);
+ });
+
+ it("continues after row failure by default", async () => {
+ const prepared = prepareBatchRender({
+ batchPath: writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"}]'),
+ outputTemplate: join(tmpDir, "renders/{name}.mp4"),
+ indexPath: writeIndex(),
+ strictVariables: false,
+ quiet: true,
+ json: false,
+ });
+
+ const seen: number[] = [];
+ const manifest = await runBatchRender({
+ prepared,
+ concurrency: 1,
+ failFast: false,
+ quiet: true,
+ json: false,
+ renderOne: async (row) => {
+ seen.push(row.index);
+ if (row.index === 0) throw new Error("boom");
+ return { renderTimeMs: 10 };
+ },
+ });
+
+ expect(seen).toEqual([0, 1]);
+ expect(manifest.failed).toBe(1);
+ expect(manifest.completed).toBe(1);
+ });
+
+ it("marks unstarted rows skipped when fail-fast is enabled", async () => {
+ const prepared = prepareBatchRender({
+ batchPath: writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"},{"name":"Cleo"}]'),
+ outputTemplate: join(tmpDir, "renders/{name}.mp4"),
+ indexPath: writeIndex(),
+ strictVariables: false,
+ quiet: true,
+ json: false,
+ });
+
+ const seen: number[] = [];
+ const manifest = await runBatchRender({
+ prepared,
+ concurrency: 1,
+ failFast: true,
+ quiet: true,
+ json: false,
+ renderOne: async (row) => {
+ seen.push(row.index);
+ if (row.index === 1) throw new Error("boom");
+ return { renderTimeMs: 10 };
+ },
+ });
+
+ expect(seen).toEqual([0, 1]);
+ expect(manifest.rows.map((row) => row.status)).toEqual(["completed", "failed", "skipped"]);
+ expect(manifest.skipped).toBe(1);
+ });
+});
diff --git a/packages/cli/src/commands/batchRender.ts b/packages/cli/src/commands/batchRender.ts
new file mode 100644
index 000000000..c62c11f9d
--- /dev/null
+++ b/packages/cli/src/commands/batchRender.ts
@@ -0,0 +1,464 @@
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, join, resolve, sep } from "node:path";
+import { c } from "../ui/colors.js";
+import { errorBox } from "../ui/format.js";
+import {
+ loadProjectVariableSchema,
+ reportVariableIssues,
+ validateVariablesAgainstSchema,
+} from "../utils/variables.js";
+
+export class BatchRenderInputError extends Error {
+ readonly title: string;
+ readonly hint: string | undefined;
+
+ constructor(title: string, message: string, hint?: string) {
+ super(message);
+ this.name = "BatchRenderInputError";
+ this.title = title;
+ this.hint = hint;
+ }
+}
+
+export interface PreparedBatchRow {
+ index: number;
+ variables: Record;
+ outputPath: string;
+}
+
+export interface PreparedBatchRender {
+ batchPath: string;
+ manifestPath: string;
+ variableIssueCount: number;
+ rows: PreparedBatchRow[];
+}
+
+export interface BatchRenderResult {
+ durationMs?: number;
+ renderTimeMs: number;
+}
+
+export interface BatchManifestRow {
+ index: number;
+ outputPath: string;
+ status: "pending" | "running" | "completed" | "failed" | "skipped";
+ durationMs: number | null;
+ renderTimeMs: number | null;
+ error: string | null;
+ startedAt: string | null;
+ completedAt: string | null;
+ variables: Record;
+}
+
+export interface BatchManifest {
+ version: 1;
+ batchPath: string;
+ manifestPath: string;
+ total: number;
+ completed: number;
+ failed: number;
+ skipped: number;
+ rows: BatchManifestRow[];
+}
+
+interface PrepareBatchRenderOptions {
+ batchPath: string;
+ outputTemplate: string;
+ indexPath: string;
+ strictVariables: boolean;
+ quiet: boolean;
+ json: boolean;
+ readFile?: (path: string) => string;
+}
+
+interface RunBatchRenderOptions {
+ prepared: PreparedBatchRender;
+ concurrency: number;
+ failFast: boolean;
+ quiet: boolean;
+ json: boolean;
+ renderOne: (row: PreparedBatchRow) => Promise;
+}
+
+const PLACEHOLDER_RE = /\{([A-Za-z0-9_.-]+)\}/g;
+
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function parseJson(raw: string, source: string): unknown {
+ try {
+ return JSON.parse(raw);
+ } catch (error: unknown) {
+ throw new BatchRenderInputError(
+ "Invalid JSON in --batch",
+ `${source}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+}
+
+export function parseBatchRows(raw: string, source: string): Record[] {
+ const parsed = parseJson(raw, source);
+ const rows = Array.isArray(parsed) ? parsed : isRecord(parsed) ? parsed.rows : undefined;
+
+ if (!Array.isArray(rows)) {
+ throw new BatchRenderInputError(
+ "Invalid batch payload",
+ '--batch must be a JSON array of objects, or an object with a "rows" array.',
+ );
+ }
+ if (rows.length === 0) {
+ throw new BatchRenderInputError("Empty batch", `${source} contains zero rows.`);
+ }
+
+ return rows.map((row, index) => {
+ if (!isRecord(row)) {
+ throw new BatchRenderInputError(
+ "Invalid batch row",
+ `Row ${index} must be a JSON object of variable values.`,
+ );
+ }
+ return row;
+ });
+}
+
+function placeholderValue(row: Record, key: string, index: number): string {
+ if (key === "index") return String(index);
+ if (!Object.hasOwn(row, key)) {
+ throw new BatchRenderInputError(
+ "Invalid output template",
+ `Missing value for placeholder {${key}} in row ${index}.`,
+ );
+ }
+
+ const value = row[key];
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+
+ throw new BatchRenderInputError(
+ "Invalid output template",
+ `Placeholder {${key}} in row ${index} must resolve to a string, number, or boolean.`,
+ );
+}
+
+export function resolveOutputTemplate(
+ template: string,
+ row: Record,
+ index: number,
+): string {
+ return template.replace(PLACEHOLDER_RE, (_match, key: string) =>
+ placeholderValue(row, key, index),
+ );
+}
+
+function isSameOrChildPath(path: string, parent: string): boolean {
+ return path === parent || path.startsWith(parent.endsWith(sep) ? parent : parent + sep);
+}
+
+export function commonOutputDirectory(outputPaths: readonly string[]): string {
+ const firstPath = outputPaths[0];
+ if (!firstPath) return resolve("renders");
+
+ let common = dirname(firstPath);
+ for (const outputPath of outputPaths.slice(1)) {
+ const dir = dirname(outputPath);
+ while (!isSameOrChildPath(dir, common)) {
+ const parent = dirname(common);
+ if (parent === common) return common;
+ common = parent;
+ }
+ }
+ return common;
+}
+
+function checkOutputCollisions(rows: readonly PreparedBatchRow[], manifestPath: string): void {
+ const seen = new Map();
+ for (const row of rows) {
+ const previous = seen.get(row.outputPath);
+ if (previous !== undefined) {
+ throw new BatchRenderInputError(
+ "Batch output collision",
+ `Rows ${previous} and ${row.index} both resolve to ${row.outputPath}.`,
+ "Use placeholders such as {index} or a unique row key in --output.",
+ );
+ }
+ if (row.outputPath === manifestPath) {
+ throw new BatchRenderInputError(
+ "Batch output collision",
+ `Row ${row.index} resolves to the manifest path: ${manifestPath}.`,
+ );
+ }
+ seen.set(row.outputPath, row.index);
+ }
+}
+
+function validateBatchVariables(
+ rows: readonly Record[],
+ indexPath: string,
+ strictVariables: boolean,
+ quiet: boolean,
+ json: boolean,
+): number {
+ const schema = loadProjectVariableSchema(indexPath);
+ let issueCount = 0;
+ const strictRows: number[] = [];
+
+ for (let index = 0; index < rows.length; index++) {
+ const row = rows[index];
+ if (!row || Object.keys(row).length === 0) continue;
+
+ const issues = validateVariablesAgainstSchema(row, schema);
+ if (issues.length === 0) continue;
+
+ issueCount += issues.length;
+ if (!quiet && !json) {
+ console.log("");
+ console.log(c.dim(`Batch row ${index}:`));
+ }
+ reportVariableIssues(issues, { strict: false, quiet: quiet || json });
+ if (strictVariables) strictRows.push(index);
+ }
+
+ if (strictRows.length > 0) {
+ throw new BatchRenderInputError(
+ "Variable validation failed",
+ `Aborting batch due to variable issues in row ${strictRows.join(", ")} (--strict-variables mode).`,
+ );
+ }
+
+ return issueCount;
+}
+
+export function prepareBatchRender(options: PrepareBatchRenderOptions): PreparedBatchRender {
+ const batchPath = resolve(options.batchPath);
+ const read = options.readFile ?? ((path: string) => readFileSync(path, "utf8"));
+ let raw: string;
+ try {
+ raw = read(batchPath);
+ } catch (error: unknown) {
+ throw new BatchRenderInputError(
+ "Could not read --batch",
+ `${batchPath}: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+
+ const variableRows = parseBatchRows(raw, batchPath);
+ const rows = variableRows.map((variables, index) => ({
+ index,
+ variables,
+ outputPath: resolve(resolveOutputTemplate(options.outputTemplate, variables, index)),
+ }));
+ const manifestPath = join(
+ commonOutputDirectory(rows.map((row) => row.outputPath)),
+ "manifest.json",
+ );
+ checkOutputCollisions(rows, manifestPath);
+
+ const variableIssueCount = validateBatchVariables(
+ variableRows,
+ options.indexPath,
+ options.strictVariables,
+ options.quiet,
+ options.json,
+ );
+
+ return {
+ batchPath,
+ manifestPath,
+ variableIssueCount,
+ rows,
+ };
+}
+
+function makeInitialManifest(prepared: PreparedBatchRender): BatchManifest {
+ return {
+ version: 1,
+ batchPath: prepared.batchPath,
+ manifestPath: prepared.manifestPath,
+ total: prepared.rows.length,
+ completed: 0,
+ failed: 0,
+ skipped: 0,
+ rows: prepared.rows.map((row) => ({
+ index: row.index,
+ outputPath: row.outputPath,
+ status: "pending",
+ durationMs: null,
+ renderTimeMs: null,
+ error: null,
+ startedAt: null,
+ completedAt: null,
+ variables: row.variables,
+ })),
+ };
+}
+
+function summarizeManifest(manifest: BatchManifest): void {
+ manifest.completed = manifest.rows.filter((row) => row.status === "completed").length;
+ manifest.failed = manifest.rows.filter((row) => row.status === "failed").length;
+ manifest.skipped = manifest.rows.filter((row) => row.status === "skipped").length;
+}
+
+function writeManifest(manifest: BatchManifest): void {
+ summarizeManifest(manifest);
+ mkdirSync(dirname(manifest.manifestPath), { recursive: true });
+ writeFileSync(manifest.manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
+}
+
+function emitJsonEvent(event: Record, json: boolean): void {
+ if (json) console.log(JSON.stringify(event));
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+async function renderBatchRow(
+ row: PreparedBatchRow,
+ manifest: BatchManifest,
+ options: RunBatchRenderOptions,
+): Promise {
+ const manifestRow = manifest.rows[row.index];
+ if (!manifestRow) {
+ throw new Error(`Batch manifest is missing row ${row.index}`);
+ }
+
+ manifestRow.status = "running";
+ manifestRow.startedAt = new Date().toISOString();
+ writeManifest(manifest);
+ emitJsonEvent(
+ { type: "batch-row-start", index: row.index, outputPath: row.outputPath },
+ options.json,
+ );
+
+ if (!options.quiet && !options.json) {
+ console.log(c.dim(`Batch row ${row.index}: ${row.outputPath}`));
+ }
+
+ try {
+ mkdirSync(dirname(row.outputPath), { recursive: true });
+ const result = await options.renderOne(row);
+ manifestRow.status = "completed";
+ manifestRow.durationMs = result.durationMs ?? null;
+ manifestRow.renderTimeMs = result.renderTimeMs;
+ manifestRow.completedAt = new Date().toISOString();
+ writeManifest(manifest);
+ emitJsonEvent(
+ {
+ type: "batch-row-complete",
+ index: row.index,
+ outputPath: row.outputPath,
+ durationMs: manifestRow.durationMs,
+ renderTimeMs: manifestRow.renderTimeMs,
+ },
+ options.json,
+ );
+ return true;
+ } catch (error: unknown) {
+ manifestRow.status = "failed";
+ manifestRow.error = errorMessage(error);
+ manifestRow.completedAt = new Date().toISOString();
+ writeManifest(manifest);
+ emitJsonEvent(
+ {
+ type: "batch-row-error",
+ index: row.index,
+ outputPath: row.outputPath,
+ error: manifestRow.error,
+ },
+ options.json,
+ );
+ if (!options.quiet && !options.json) {
+ console.log(c.error(` Row ${row.index} failed: ${manifestRow.error}`));
+ }
+ return false;
+ }
+}
+
+function markUnstartedRowsSkipped(manifest: BatchManifest): void {
+ for (const row of manifest.rows) {
+ if (row.status !== "pending") continue;
+ row.status = "skipped";
+ row.error = "Skipped after --batch-fail-fast.";
+ row.completedAt = new Date().toISOString();
+ }
+}
+
+export async function runBatchRender(options: RunBatchRenderOptions): Promise {
+ if (options.concurrency < 1) {
+ throw new BatchRenderInputError(
+ "Invalid batch-concurrency",
+ `Got "${options.concurrency}". Must be a positive integer.`,
+ );
+ }
+
+ const manifest = makeInitialManifest(options.prepared);
+ writeManifest(manifest);
+
+ if (!options.quiet && !options.json) {
+ console.log("");
+ console.log(
+ c.accent("◆") +
+ ` Batch rendering ${options.prepared.rows.length} rows` +
+ c.dim(` → ${options.prepared.manifestPath}`),
+ );
+ console.log(c.dim(` batch concurrency: ${options.concurrency}`));
+ console.log("");
+ }
+
+ let cursor = 0;
+ let stopLaunching = false;
+ const workerCount = Math.min(options.concurrency, options.prepared.rows.length);
+
+ await Promise.all(
+ Array.from({ length: workerCount }, async () => {
+ while (!stopLaunching) {
+ const row = options.prepared.rows[cursor];
+ if (!row) return;
+ cursor++;
+
+ const ok = await renderBatchRow(row, manifest, options);
+ if (!ok && options.failFast) {
+ stopLaunching = true;
+ }
+ }
+ }),
+ );
+
+ if (stopLaunching) markUnstartedRowsSkipped(manifest);
+ writeManifest(manifest);
+ emitJsonEvent(
+ {
+ type: "batch-complete",
+ manifestPath: manifest.manifestPath,
+ total: manifest.total,
+ completed: manifest.completed,
+ failed: manifest.failed,
+ skipped: manifest.skipped,
+ },
+ options.json,
+ );
+
+ if (!options.quiet && !options.json) {
+ console.log("");
+ console.log(
+ manifest.failed > 0
+ ? c.warn(
+ `Batch complete: ${manifest.completed} completed, ${manifest.failed} failed, ${manifest.skipped} skipped.`,
+ )
+ : c.success(`Batch complete: ${manifest.completed} completed.`),
+ );
+ console.log(c.dim(`Manifest: ${manifest.manifestPath}`));
+ }
+
+ return manifest;
+}
+
+export function exitBatchRenderInputError(error: unknown): never {
+ if (error instanceof BatchRenderInputError) {
+ errorBox(error.title, error.message, error.hint);
+ process.exit(1);
+ }
+ throw error;
+}
diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts
index 848f7cee0..eb11797a7 100644
--- a/packages/cli/src/commands/render.ts
+++ b/packages/cli/src/commands/render.ts
@@ -42,6 +42,10 @@ export const examples: Example[] = [
"Variables from a JSON file",
"hyperframes render --variables-file ./vars.json --output out.mp4",
],
+ [
+ "Batch render one output per variables row",
+ 'hyperframes render --batch rows.json --output "renders/{name}.mp4"',
+ ],
];
import { cpus, freemem, tmpdir } from "node:os";
import { resolve, dirname, join, basename } from "node:path";
@@ -248,6 +252,26 @@ export default defineCommand({
"Fail render if any --variables key is undeclared or has a wrong type vs the composition's data-composition-variables. Without this flag, mismatches are warnings.",
default: false,
},
+ batch: {
+ type: "string",
+ description:
+ 'Path to a JSON array of variable rows (or {"rows":[...]}). Renders one output per row.',
+ },
+ "batch-concurrency": {
+ type: "string",
+ description:
+ "Maximum number of batch rows to render at once. Default: 1, because each render already parallelizes across workers.",
+ },
+ "batch-fail-fast": {
+ type: "boolean",
+ description: "Stop launching new batch rows after the first row failure.",
+ default: false,
+ },
+ json: {
+ type: "boolean",
+ description: "With --batch, emit JSON progress events.",
+ default: false,
+ },
resolution: {
type: "string",
description:
@@ -440,6 +464,39 @@ export default defineCommand({
process.env.PRODUCER_MAX_CONCURRENT_RENDERS = String(parsed);
}
+ // ── Validate batch mode ───────────────────────────────────────────────
+ const batchPath =
+ typeof args.batch === "string" && args.batch.trim() !== "" ? args.batch.trim() : undefined;
+ if (batchPath && (args.variables != null || args["variables-file"] != null)) {
+ errorBox(
+ "Conflicting variables flags",
+ "Use either --batch or --variables/--variables-file, not both.",
+ );
+ process.exit(1);
+ }
+
+ if (!batchPath && args["batch-concurrency"] != null) {
+ errorBox("Invalid batch-concurrency", "--batch-concurrency requires --batch.");
+ process.exit(1);
+ }
+ if (!batchPath && args["batch-fail-fast"]) {
+ errorBox("Invalid batch-fail-fast", "--batch-fail-fast requires --batch.");
+ process.exit(1);
+ }
+
+ let batchConcurrency = 1;
+ if (args["batch-concurrency"] != null) {
+ const parsed = parseInt(args["batch-concurrency"], 10);
+ if (isNaN(parsed) || parsed < 1) {
+ errorBox(
+ "Invalid batch-concurrency",
+ `Got "${args["batch-concurrency"]}". Must be a positive integer.`,
+ );
+ process.exit(1);
+ }
+ batchConcurrency = parsed;
+ }
+
// ── Resolve output path ───────────────────────────────────────────────
const rendersDir = resolve("renders");
const ext = FORMAT_EXT[format] ?? ".mp4";
@@ -447,18 +504,23 @@ export default defineCommand({
const now = new Date();
const datePart = now.toISOString().slice(0, 10);
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
+ const batchOutputTemplate = args.output
+ ? args.output
+ : join(rendersDir, `${project.name}_${datePart}_${timePart}_{index}${ext}`);
const outputPath = args.output
? resolve(args.output)
: join(rendersDir, `${project.name}_${datePart}_${timePart}${ext}`);
// Ensure output directory exists
- mkdirSync(dirname(outputPath), { recursive: true });
+ if (!batchPath) mkdirSync(dirname(outputPath), { recursive: true });
const useDocker = args.docker ?? false;
const useGpu = args.gpu ?? false;
const browserGpuArg = args["browser-gpu"];
const browserGpuMode = resolveBrowserGpuForCli(useDocker, browserGpuArg);
const quiet = args.quiet ?? false;
+ const batchJson = args.json ?? false;
+ const effectiveQuiet = quiet || (batchPath != null && batchJson);
const strictAll = args["strict-all"] ?? false;
const strictErrors = (args.strict ?? false) || strictAll;
const crfRaw = args.crf;
@@ -507,8 +569,27 @@ export default defineCommand({
const pageNavigationTimeoutMs = resolveBrowserTimeoutMsArg(args["browser-timeout"]);
const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync);
+ // ── Preflight batch rows before browser/lint work ────────────────────
+ let batchModule: typeof import("./batchRender.js") | undefined;
+ let preparedBatch: import("./batchRender.js").PreparedBatchRender | undefined;
+ if (batchPath) {
+ batchModule = await import("./batchRender.js");
+ try {
+ preparedBatch = batchModule.prepareBatchRender({
+ batchPath,
+ outputTemplate: batchOutputTemplate,
+ indexPath: project.indexPath,
+ strictVariables: args["strict-variables"] ?? false,
+ quiet: quiet || batchJson,
+ json: batchJson,
+ });
+ } catch (error: unknown) {
+ batchModule.exitBatchRenderInputError(error);
+ }
+ }
+
// ── Print render plan ─────────────────────────────────────────────────
- if (!quiet) {
+ if (!quiet && !batchPath) {
const workerLabel =
workers != null ? `${workers} workers` : `auto workers (${CPU_CORE_COUNT} cores detected)`;
console.log("");
@@ -544,23 +625,35 @@ export default defineCommand({
let browserPath: string | undefined;
if (!useDocker) {
const { ensureBrowser } = await import("../browser/manager.js");
- const clack = await import("@clack/prompts");
- const s = clack.spinner();
- s.start("Checking browser...");
+ let browserSpinner:
+ | {
+ start: (message?: string) => void;
+ message: (message: string) => void;
+ stop: (message?: string) => void;
+ }
+ | undefined;
try {
- const info = await ensureBrowser({
- onProgress: (downloaded, total) => {
- if (total <= 0) return;
- const pct = Math.floor((downloaded / total) * 100);
- s.message(
- `Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
- );
- },
- });
- browserPath = info.executablePath;
- s.stop(c.dim(`Browser: ${info.source}`));
+ if (effectiveQuiet) {
+ const info = await ensureBrowser();
+ browserPath = info.executablePath;
+ } else {
+ const clack = await import("@clack/prompts");
+ browserSpinner = clack.spinner();
+ browserSpinner.start("Checking browser...");
+ const info = await ensureBrowser({
+ onProgress: (downloaded, total) => {
+ if (total <= 0) return;
+ const pct = Math.floor((downloaded / total) * 100);
+ browserSpinner?.message(
+ `Downloading Chrome... ${c.progress(pct + "%")} ${c.dim("(" + formatBytes(downloaded) + " / " + formatBytes(total) + ")")}`,
+ );
+ },
+ });
+ browserPath = info.executablePath;
+ browserSpinner.stop(c.dim(`Browser: ${info.source}`));
+ }
} catch (err: unknown) {
- s.stop(c.error("Browser not available"));
+ browserSpinner?.stop(c.error("Browser not available"));
errorBox(
"Chrome not found",
err instanceof Error ? err.message : String(err),
@@ -601,6 +694,57 @@ export default defineCommand({
process.exit(1);
}
+ // ── Batch render ──────────────────────────────────────────────────────
+ if (batchPath && batchModule && preparedBatch) {
+ const batchQuiet = quiet || batchJson;
+ const hdrMode: RenderOptions["hdrMode"] = args.sdr
+ ? "force-sdr"
+ : args.hdr
+ ? "force-hdr"
+ : "auto";
+ const renderOptionsBase: RenderOptions = {
+ fps,
+ quality,
+ format,
+ workers,
+ gpu: useGpu,
+ browserGpuMode,
+ hdrMode,
+ crf,
+ videoBitrate,
+ quiet: batchQuiet,
+ browserPath,
+ entryFile,
+ outputResolution,
+ pageNavigationTimeoutMs,
+ protocolTimeout,
+ playerReadyTimeout,
+ exitAfterComplete: false,
+ throwOnError: true,
+ skipFeedback: true,
+ };
+ const manifest = await batchModule.runBatchRender({
+ prepared: preparedBatch,
+ concurrency: batchConcurrency,
+ failFast: args["batch-fail-fast"] ?? false,
+ quiet: batchQuiet,
+ json: batchJson,
+ renderOne: (row) =>
+ useDocker
+ ? renderDocker(project.dir, row.outputPath, {
+ ...renderOptionsBase,
+ variables: row.variables,
+ pageSideCompositing: args["page-side-compositing"] !== false,
+ })
+ : renderLocal(project.dir, row.outputPath, {
+ ...renderOptionsBase,
+ variables: row.variables,
+ }),
+ });
+ if (manifest.failed > 0) process.exitCode = 1;
+ return;
+ }
+
// ── Resolve --variables / --variables-file ──────────────────────────
const variables = resolveVariablesArg(args.variables, args["variables-file"]);
@@ -660,6 +804,11 @@ export default defineCommand({
},
});
+export interface SingleRenderResult {
+ durationMs?: number;
+ renderTimeMs: number;
+}
+
interface RenderOptions {
fps: Fps;
quality: "draft" | "standard" | "high";
@@ -695,6 +844,10 @@ interface RenderOptions {
protocolTimeout?: number;
/** Player-ready timeout override (ms). */
playerReadyTimeout?: number;
+ /** Throw render failures to the caller instead of printing and exiting. */
+ throwOnError?: boolean;
+ /** Skip the interactive feedback prompt after a successful render. */
+ skipFeedback?: boolean;
}
/**
@@ -868,7 +1021,7 @@ async function renderDocker(
projectDir: string,
outputPath: string,
options: RenderOptions,
-): Promise {
+): Promise {
const startTime = Date.now();
// Dev mode (tsx/ts-node) uses "latest" since the local version isn't on npm
@@ -959,6 +1112,7 @@ async function renderDocker(
printRenderComplete(outputPath, elapsed, options.quiet);
if (options.exitAfterComplete) scheduleRenderProcessExit();
+ return { renderTimeMs: elapsed };
}
// fallow-ignore-next-line complexity
@@ -966,7 +1120,7 @@ export async function renderLocal(
projectDir: string,
outputPath: string,
options: RenderOptions,
-): Promise {
+): Promise {
const producer = await loadProducer();
if (!findFFmpeg()) {
@@ -1038,11 +1192,17 @@ export async function renderLocal(
const elapsed = Date.now() - startTime;
trackRenderMetrics(job, elapsed, options, false);
printRenderComplete(outputPath, elapsed, options.quiet);
- await maybePromptRenderFeedback({
- renderDurationMs: elapsed,
- quiet: options.quiet,
- });
+ if (!options.skipFeedback) {
+ await maybePromptRenderFeedback({
+ renderDurationMs: elapsed,
+ quiet: options.quiet,
+ });
+ }
if (options.exitAfterComplete) scheduleRenderProcessExit();
+ const durationMs = job.perfSummary
+ ? Math.round(job.perfSummary.compositionDurationSeconds * 1000)
+ : undefined;
+ return { renderTimeMs: elapsed, durationMs };
}
type UnrefableTimer = {
@@ -1177,6 +1337,9 @@ function handleRenderError(
...renderJobObservabilityTelemetryPayload(job),
...getMemorySnapshot(),
});
+ if (options.throwOnError) {
+ throw new Error(message);
+ }
errorBox("Render failed", message, hint);
process.exit(1);
}