mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
feat(cli): batch rendering — one output per variables row with manifest (#1336)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
co-authored by
Matt Van Horn
parent
d580f2a1d8
commit
e5a78ef6a2
@@ -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",
|
||||
`<html data-composition-variables='${schema}'><body><div data-composition-id="root"></div></body></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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user