feat(cli): add --resolution flag to hyperframes render for one-line 4k

This commit is contained in:
James
2026-05-07 16:58:25 +00:00
parent c1c7ba999a
commit e07aeba213
8 changed files with 401 additions and 1 deletions
+63
View File
@@ -5,6 +5,10 @@ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync }
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"],
[
"Upsample any composition to 4K (supersamples via Chrome DPR)",
"hyperframes render --resolution 4k --output 4k.mp4",
],
["Render transparent overlay (ProRes)", "hyperframes render --format mov --output overlay.mov"],
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
[
@@ -47,8 +51,35 @@ import {
validateVariables,
formatVariableValidationIssue,
type VariableValidationIssue,
type CanvasResolution,
} from "@hyperframes/core";
const VALID_RENDER_RESOLUTIONS: readonly CanvasResolution[] = [
"landscape",
"portrait",
"landscape-4k",
"portrait-4k",
] as const;
const RENDER_RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
"1080p": "landscape",
hd: "landscape",
"1080p-portrait": "portrait",
"portrait-1080p": "portrait",
"4k": "landscape-4k",
uhd: "landscape-4k",
"4k-portrait": "portrait-4k",
};
function normalizeRenderResolutionFlag(input: string | undefined): CanvasResolution | undefined {
if (!input) return undefined;
const lowered = input.toLowerCase();
if ((VALID_RENDER_RESOLUTIONS as readonly string[]).includes(lowered)) {
return lowered as CanvasResolution;
}
return RENDER_RESOLUTION_ALIASES[lowered];
}
const VALID_FPS = new Set([24, 30, 60]);
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const VALID_FORMAT = new Set(["mp4", "webm", "mov", "png-sequence"]);
@@ -177,6 +208,11 @@ 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,
},
resolution: {
type: "string",
description:
"Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840). Aliases: 1080p, 4k, uhd. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.",
},
},
async run({ args }) {
// ── Resolve project ────────────────────────────────────────────────────
@@ -206,6 +242,19 @@ export default defineCommand({
}
const format = formatRaw as "mp4" | "webm" | "mov" | "png-sequence";
// ── Validate resolution ────────────────────────────────────────────────
let outputResolution: CanvasResolution | undefined;
if (args.resolution !== undefined) {
outputResolution = normalizeRenderResolutionFlag(args.resolution);
if (!outputResolution) {
errorBox(
"Invalid resolution",
`Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k (or aliases 1080p, 4k, uhd).`,
);
process.exit(1);
}
}
// ── Validate workers ──────────────────────────────────────────────────
let workers: number | undefined;
if (args.workers != null && args.workers !== "auto") {
@@ -319,6 +368,9 @@ export default defineCommand({
c.accent("\u25C6") + " Rendering " + c.accent(nameLabel) + c.dim(" \u2192 " + outputPath),
);
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel));
if (outputResolution) {
console.log(c.dim(" Output resolution: " + outputResolution + " (supersampled via DPR)"));
}
if (useGpu || browserGpuMode !== "software") {
const gpuModes = [
useGpu ? "encoder GPU" : null,
@@ -452,6 +504,7 @@ export default defineCommand({
quiet,
variables,
entryFile,
outputResolution,
exitAfterComplete: true,
});
} else {
@@ -469,6 +522,7 @@ export default defineCommand({
browserPath,
variables,
entryFile,
outputResolution,
exitAfterComplete: true,
});
}
@@ -495,6 +549,13 @@ interface RenderOptions {
variables?: Record<string, unknown>;
entryFile?: string;
exitAfterComplete?: boolean;
/**
* Output resolution preset. When set, the orchestrator computes a Chrome
* deviceScaleFactor so the screenshot lands at the requested dimensions
* without changing the composition. See the producer's
* `resolveDeviceScaleFactor` for the integer-scale + aspect constraints.
*/
outputResolution?: CanvasResolution;
}
export type VariablesParseError =
@@ -788,6 +849,7 @@ async function renderDocker(
quiet: options.quiet,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
},
});
@@ -859,6 +921,7 @@ export async function renderLocal(
videoBitrate: options.videoBitrate,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
});
const onProgress = options.quiet
@@ -239,4 +239,19 @@ describe("buildDockerRunArgs", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--composition");
});
it("forwards --resolution to the container when outputResolution is set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
options: { ...BASE, outputResolution: "landscape-4k" },
});
const idx = args.indexOf("--resolution");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe("landscape-4k");
});
it("omits --resolution when outputResolution is not set", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--resolution");
});
});
+3
View File
@@ -31,6 +31,8 @@ export interface DockerRenderOptions {
quiet: boolean;
variables?: Record<string, unknown>;
entryFile?: string;
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
outputResolution?: string;
}
export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
@@ -69,5 +71,6 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
? ["--variables", JSON.stringify(options.variables)]
: []),
...(options.entryFile ? ["--composition", options.entryFile] : []),
...(options.outputResolution ? ["--resolution", options.outputResolution] : []),
];
}