Skip to content

Commit e5a78ef

Browse files
authored
feat(cli): batch rendering — one output per variables row with manifest (#1336)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
1 parent d580f2a commit e5a78ef

6 files changed

Lines changed: 973 additions & 23 deletions

File tree

docs/concepts/variables.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,25 @@ npx hyperframes render --variables '{"title":"Q4 Report"}' --strict-variables --
193193
CLI overrides apply only to the top-level composition. Sub-composition variables are controlled by `data-variable-values` on each host element.
194194
</Note>
195195

196+
## Batch Renders
197+
198+
Use `--batch` when the same composition should render once per data row:
199+
200+
```json rows.json
201+
[
202+
{ "name": "Alice", "title": "Q4 Report" },
203+
{ "name": "Bob", "title": "Renewal Plan" }
204+
]
205+
```
206+
207+
```bash Terminal
208+
npx hyperframes render --batch rows.json --output "renders/{name}.mp4" --strict-variables
209+
```
210+
211+
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.
212+
213+
For small compositions, `--batch-concurrency 2` can run rows in parallel. The default is `1` because each individual render already parallelizes across render workers.
214+
196215
## Layering and Precedence
197216

198217
Variable values are resolved by merging three sources, lowest to highest precedence:

docs/guides/pipeline.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ The pipeline delivers the localhost Studio URL as the handoff. Your AI agent run
178178
npx hyperframes render --output my-video.mp4
179179
```
180180

181+
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.
182+
181183
**Gate:** `lint` and `validate` pass with zero errors. Snapshot frames look right. The Studio preview URL is ready to share.
182184

183185
## Iterating

docs/guides/rendering.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, WebM
124124
| `--video-bitrate` | e.g. `10M`, `5000k` || Target bitrate encoding. Cannot combine with `--crf` |
125125
| `--workers` | 1-8 or `auto` | auto | Parallel render workers (see [Workers](#workers) below) |
126126
| `--max-concurrent-renders` | 1-10 | 2 | Max simultaneous renders via the producer server (see [Concurrent Renders](#concurrent-renders) below) |
127+
| `--batch` | path || JSON array of variable rows (or `{ "rows": [...] }`), rendering one output per row |
128+
| `--batch-concurrency` | integer | 1 | Maximum batch rows to render at once |
129+
| `--batch-fail-fast` || off | Stop launching new batch rows after the first row failure |
127130
| `--gpu` || off | GPU encoding (NVENC, VideoToolbox, AMF, VAAPI, QSV) |
128131
| `--browser-gpu` / `--no-browser-gpu` || on locally, off in Docker | Use or opt out of host GPU acceleration for local Chrome/WebGL capture |
129132
| `--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
231234
- Dedicated render machines or CI runners
232235
- Docker mode on a well-provisioned host
233236

237+
## Batch Rendering
238+
239+
Batch rendering runs the same composition once per variables row:
240+
241+
```json rows.json
242+
[
243+
{ "name": "Alice", "headline": "Welcome, Alice" },
244+
{ "name": "Bob", "headline": "Welcome, Bob" }
245+
]
246+
```
247+
248+
```bash Terminal
249+
npx hyperframes render --batch rows.json --output "renders/{name}.mp4" --strict-variables
250+
```
251+
252+
`--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.
253+
254+
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.
255+
234256
## Concurrent Renders
235257

236258
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.
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join, resolve } from "node:path";
5+
import {
6+
BatchRenderInputError,
7+
parseBatchRows,
8+
prepareBatchRender,
9+
resolveOutputTemplate,
10+
runBatchRender,
11+
} from "./batchRender.js";
12+
13+
let tmpDir: string;
14+
15+
beforeEach(() => {
16+
tmpDir = mkdtempSync(join(tmpdir(), "hf-local-batch-render-"));
17+
});
18+
19+
afterEach(() => {
20+
rmSync(tmpDir, { recursive: true, force: true });
21+
vi.restoreAllMocks();
22+
});
23+
24+
function writeJson(name: string, content: string): string {
25+
const path = join(tmpDir, name);
26+
writeFileSync(path, content, "utf8");
27+
return path;
28+
}
29+
30+
function writeIndex(schema = "[]"): string {
31+
return writeJson(
32+
"index.html",
33+
`<html data-composition-variables='${schema}'><body><div data-composition-id="root"></div></body></html>`,
34+
);
35+
}
36+
37+
function expectBatchError(fn: () => unknown, title: string): BatchRenderInputError {
38+
try {
39+
fn();
40+
} catch (error: unknown) {
41+
expect(error).toBeInstanceOf(BatchRenderInputError);
42+
if (error instanceof BatchRenderInputError) {
43+
expect(error.title).toBe(title);
44+
return error;
45+
}
46+
}
47+
throw new Error("Expected BatchRenderInputError");
48+
}
49+
50+
function eventType(value: unknown): string | undefined {
51+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
52+
return "type" in value && typeof value.type === "string" ? value.type : undefined;
53+
}
54+
55+
describe("parseBatchRows", () => {
56+
it("parses a JSON array of variable rows", () => {
57+
expect(parseBatchRows('[{"name":"Alice"},{"name":"Bob"}]', "rows.json")).toEqual([
58+
{ name: "Alice" },
59+
{ name: "Bob" },
60+
]);
61+
});
62+
63+
it("parses an object with a rows array", () => {
64+
expect(parseBatchRows('{"rows":[{"name":"Alice"}]}', "rows.json")).toEqual([{ name: "Alice" }]);
65+
});
66+
67+
it("rejects non-object rows", () => {
68+
const error = expectBatchError(
69+
() => parseBatchRows('[{"name":"Alice"},null]', "rows.json"),
70+
"Invalid batch row",
71+
);
72+
expect(error.message).toMatch(/Row 1/);
73+
});
74+
});
75+
76+
describe("resolveOutputTemplate", () => {
77+
it("replaces row placeholders and index", () => {
78+
expect(resolveOutputTemplate("renders/{name}-{index}.mp4", { name: "Alice" }, 3)).toBe(
79+
"renders/Alice-3.mp4",
80+
);
81+
});
82+
83+
it("rejects missing placeholder keys", () => {
84+
const error = expectBatchError(
85+
() => resolveOutputTemplate("renders/{slug}.mp4", { name: "Alice" }, 0),
86+
"Invalid output template",
87+
);
88+
expect(error.message).toMatch(/Missing value/);
89+
});
90+
});
91+
92+
describe("prepareBatchRender", () => {
93+
it("resolves output paths and the manifest path", () => {
94+
const batchPath = writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"}]');
95+
const outDir = join(tmpDir, "renders");
96+
const prepared = prepareBatchRender({
97+
batchPath,
98+
outputTemplate: join(outDir, "{name}.mp4"),
99+
indexPath: writeIndex(),
100+
strictVariables: false,
101+
quiet: true,
102+
json: false,
103+
});
104+
105+
expect(prepared.rows.map((row) => row.outputPath)).toEqual([
106+
resolve(outDir, "Alice.mp4"),
107+
resolve(outDir, "Bob.mp4"),
108+
]);
109+
expect(prepared.manifestPath).toBe(resolve(outDir, "manifest.json"));
110+
});
111+
112+
it("rejects output collisions before rendering", () => {
113+
const batchPath = writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"}]');
114+
const error = expectBatchError(
115+
() =>
116+
prepareBatchRender({
117+
batchPath,
118+
outputTemplate: join(tmpDir, "same.mp4"),
119+
indexPath: writeIndex(),
120+
strictVariables: false,
121+
quiet: true,
122+
json: false,
123+
}),
124+
"Batch output collision",
125+
);
126+
expect(error.message).toMatch(/Rows 0 and 1/);
127+
});
128+
129+
it("fails strict variable validation per row", () => {
130+
const batchPath = writeJson("rows.json", '[{"title":"Hello"},{"title":3}]');
131+
const schema = '[{"id":"title","type":"string","label":"Title","default":"Untitled"}]';
132+
const error = expectBatchError(
133+
() =>
134+
prepareBatchRender({
135+
batchPath,
136+
outputTemplate: join(tmpDir, "{index}.mp4"),
137+
indexPath: writeIndex(schema),
138+
strictVariables: true,
139+
quiet: true,
140+
json: true,
141+
}),
142+
"Variable validation failed",
143+
);
144+
expect(error.message).toMatch(/row 1/);
145+
});
146+
147+
it("counts non-strict variable validation issues without failing", () => {
148+
const batchPath = writeJson("rows.json", '[{"title":3}]');
149+
const schema = '[{"id":"title","type":"string","label":"Title","default":"Untitled"}]';
150+
const prepared = prepareBatchRender({
151+
batchPath,
152+
outputTemplate: join(tmpDir, "{index}.mp4"),
153+
indexPath: writeIndex(schema),
154+
strictVariables: false,
155+
quiet: true,
156+
json: false,
157+
});
158+
159+
expect(prepared.variableIssueCount).toBe(1);
160+
});
161+
});
162+
163+
describe("runBatchRender", () => {
164+
it("writes a manifest with completed rows", async () => {
165+
const prepared = prepareBatchRender({
166+
batchPath: writeJson("rows.json", '[{"name":"Alice"}]'),
167+
outputTemplate: join(tmpDir, "renders/{name}.mp4"),
168+
indexPath: writeIndex(),
169+
strictVariables: false,
170+
quiet: true,
171+
json: false,
172+
});
173+
174+
const manifest = await runBatchRender({
175+
prepared,
176+
concurrency: 1,
177+
failFast: false,
178+
quiet: true,
179+
json: false,
180+
renderOne: async () => ({ durationMs: 3000, renderTimeMs: 42 }),
181+
});
182+
183+
expect(manifest.completed).toBe(1);
184+
expect(manifest.failed).toBe(0);
185+
expect(manifest.rows[0]).toMatchObject({
186+
index: 0,
187+
status: "completed",
188+
durationMs: 3000,
189+
renderTimeMs: 42,
190+
error: null,
191+
});
192+
expect(readFileSync(prepared.manifestPath, "utf8")).toContain('"status": "completed"');
193+
});
194+
195+
it("emits JSON progress events when json mode is enabled", async () => {
196+
const prepared = prepareBatchRender({
197+
batchPath: writeJson("rows.json", '[{"name":"Alice"}]'),
198+
outputTemplate: join(tmpDir, "renders/{name}.mp4"),
199+
indexPath: writeIndex(),
200+
strictVariables: false,
201+
quiet: true,
202+
json: true,
203+
});
204+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
205+
206+
await runBatchRender({
207+
prepared,
208+
concurrency: 1,
209+
failFast: false,
210+
quiet: true,
211+
json: true,
212+
renderOne: async () => ({ renderTimeMs: 10 }),
213+
});
214+
215+
const events = log.mock.calls.map((call): unknown => JSON.parse(String(call[0])));
216+
expect(events.map(eventType)).toEqual([
217+
"batch-row-start",
218+
"batch-row-complete",
219+
"batch-complete",
220+
]);
221+
});
222+
223+
it("continues after row failure by default", async () => {
224+
const prepared = prepareBatchRender({
225+
batchPath: writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"}]'),
226+
outputTemplate: join(tmpDir, "renders/{name}.mp4"),
227+
indexPath: writeIndex(),
228+
strictVariables: false,
229+
quiet: true,
230+
json: false,
231+
});
232+
233+
const seen: number[] = [];
234+
const manifest = await runBatchRender({
235+
prepared,
236+
concurrency: 1,
237+
failFast: false,
238+
quiet: true,
239+
json: false,
240+
renderOne: async (row) => {
241+
seen.push(row.index);
242+
if (row.index === 0) throw new Error("boom");
243+
return { renderTimeMs: 10 };
244+
},
245+
});
246+
247+
expect(seen).toEqual([0, 1]);
248+
expect(manifest.failed).toBe(1);
249+
expect(manifest.completed).toBe(1);
250+
});
251+
252+
it("marks unstarted rows skipped when fail-fast is enabled", async () => {
253+
const prepared = prepareBatchRender({
254+
batchPath: writeJson("rows.json", '[{"name":"Alice"},{"name":"Bob"},{"name":"Cleo"}]'),
255+
outputTemplate: join(tmpDir, "renders/{name}.mp4"),
256+
indexPath: writeIndex(),
257+
strictVariables: false,
258+
quiet: true,
259+
json: false,
260+
});
261+
262+
const seen: number[] = [];
263+
const manifest = await runBatchRender({
264+
prepared,
265+
concurrency: 1,
266+
failFast: true,
267+
quiet: true,
268+
json: false,
269+
renderOne: async (row) => {
270+
seen.push(row.index);
271+
if (row.index === 1) throw new Error("boom");
272+
return { renderTimeMs: 10 };
273+
},
274+
});
275+
276+
expect(seen).toEqual([0, 1]);
277+
expect(manifest.rows.map((row) => row.status)).toEqual(["completed", "failed", "skipped"]);
278+
expect(manifest.skipped).toBe(1);
279+
});
280+
});

0 commit comments

Comments
 (0)