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
4 changes: 4 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
```bash
npx hyperframes render --output output.mp4
```
Render a specific composition instead of `index.html`:
```bash
npx hyperframes render -c compositions/intro.html -o intro.mp4
```
For deterministic output, add `--docker`:
```bash
npx hyperframes render --docker --output output.mp4
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,37 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.variables).toBeUndefined();
});

it("forwards entryFile to createRenderJob when --composition is set", 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,
entryFile: "compositions/intro.html",
});

expect(producerState.createdJobs[0]?.entryFile).toBe("compositions/intro.html");
});

it("omits entryFile from createRenderJob when --composition is not set", 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]?.entryFile).toBeUndefined();
});

it("can force the CLI process to exit after a successful local render", async () => {
vi.useFakeTimers();
const exit = vi
Expand Down
43 changes: 39 additions & 4 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:f

export const examples: Example[] = [
["Render to MP4", "hyperframes render --output output.mp4"],
["Render a specific composition", "hyperframes render -c compositions/intro.html -o intro.mp4"],
["Render transparent overlay (ProRes)", "hyperframes render --format mov --output overlay.mov"],
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
Expand Down Expand Up @@ -62,6 +63,13 @@ export default defineCommand({
description: "Project directory",
required: false,
},
composition: {
type: "string",
alias: "c",
description:
"Render a specific composition file instead of index.html (e.g. compositions/intro.html). " +
"Sub-compositions using <template> wrappers must be referenced from index.html via data-composition-src.",
},
output: {
type: "string",
alias: "o",
Expand Down Expand Up @@ -263,16 +271,38 @@ export default defineCommand({
process.exit(1);
}

// ── Validate composition entry file ──────────────────────────────────
const entryFile = args.composition?.trim().replace(/^\.\//, "") || undefined;
if (entryFile) {
const absProjectDir = resolve(project.dir);
const entryPath = resolve(absProjectDir, entryFile);
if (!entryPath.startsWith(absProjectDir)) {
errorBox(
"Invalid composition path",
`Entry file must stay inside the project directory: ${entryFile}`,
);
process.exit(1);
}
try {
statSync(entryPath);
} catch {
errorBox(
"Composition not found",
`"${entryFile}" does not exist in the project directory.`,
"Pass a path to a .html file relative to the project root (e.g. compositions/intro.html).",
);
process.exit(1);
}
}

// ── Print render plan ─────────────────────────────────────────────────
if (!quiet) {
const workerLabel =
workers != null ? `${workers} workers` : `auto workers (${CPU_CORE_COUNT} cores detected)`;
console.log("");
const nameLabel = entryFile ? project.name + "/" + entryFile : project.name;
console.log(
c.accent("\u25C6") +
" Rendering " +
c.accent(project.name) +
c.dim(" \u2192 " + outputPath),
c.accent("\u25C6") + " Rendering " + c.accent(nameLabel) + c.dim(" \u2192 " + outputPath),
);
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel));
if (useGpu || useBrowserGpu) {
Expand Down Expand Up @@ -403,6 +433,7 @@ export default defineCommand({
videoBitrate,
quiet,
variables,
entryFile,
exitAfterComplete: true,
});
} else {
Expand All @@ -419,6 +450,7 @@ export default defineCommand({
quiet,
browserPath,
variables,
entryFile,
exitAfterComplete: true,
});
}
Expand All @@ -438,6 +470,7 @@ interface RenderOptions {
quiet: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
entryFile?: string;
exitAfterComplete?: boolean;
}

Expand Down Expand Up @@ -713,6 +746,7 @@ async function renderDocker(
videoBitrate: options.videoBitrate,
quiet: options.quiet,
variables: options.variables,
entryFile: options.entryFile,
},
});

Expand Down Expand Up @@ -783,6 +817,7 @@ export async function renderLocal(
crf: options.crf,
videoBitrate: options.videoBitrate,
variables: options.variables,
entryFile: options.entryFile,
});

const onProgress = options.quiet
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ describe("buildDockerRunArgs", () => {
crf: 16,
videoBitrate: undefined,
quiet: true,
entryFile: "compositions/intro.html",
},
});
// Each value must reach the container exactly once. If a future option
Expand All @@ -176,6 +177,8 @@ describe("buildDockerRunArgs", () => {
expect(args).toContain("--gpu");
expect(args).toContain("--no-browser-gpu");
expect(args).toContain("--hdr");
expect(args).toContain("--composition");
expect(args).toContain("compositions/intro.html");
});

it("forwards --video-bitrate to the container when set", () => {
Expand Down Expand Up @@ -210,4 +213,19 @@ describe("buildDockerRunArgs", () => {
});
expect(args).not.toContain("--variables");
});

it("forwards --composition to the container when entryFile is set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, entryFile: "compositions/intro.html" },
});
const idx = args.indexOf("--composition");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe("compositions/intro.html");
});

it("omits --composition when entryFile is not set", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--composition");
});
});
2 changes: 2 additions & 0 deletions packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface DockerRenderOptions {
videoBitrate?: string;
quiet: boolean;
variables?: Record<string, unknown>;
entryFile?: string;
}

export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
Expand Down Expand Up @@ -67,5 +68,6 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
...(options.variables && Object.keys(options.variables).length > 0
? ["--variables", JSON.stringify(options.variables)]
: []),
...(options.entryFile ? ["--composition", options.entryFile] : []),
];
}
Loading