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] : []),
];
}
@@ -20,6 +20,7 @@ import {
isRecoverableParallelCaptureError,
materializeExtractedFramesForCompiledDir,
projectBrowserEndToCompositionTimeline,
resolveDeviceScaleFactor,
resolveRenderWorkerCount,
resolveCompositeTransfer,
selectCaptureCalibrationFrames,
@@ -749,3 +750,82 @@ describe("projectBrowserEndToCompositionTimeline", () => {
expect(projectBrowserEndToCompositionTimeline(21.5, 1.5, 5.5)).toBe(25.5);
});
});
describe("resolveDeviceScaleFactor", () => {
const defaults = {
compositionWidth: 1920,
compositionHeight: 1080,
hdrRequested: false,
} as const;
it("returns 1 when no outputResolution is set (default behavior)", () => {
expect(resolveDeviceScaleFactor({ ...defaults, outputResolution: undefined })).toBe(1);
});
it("returns 2 for the canonical 1080p → 4K supersample", () => {
expect(resolveDeviceScaleFactor({ ...defaults, outputResolution: "landscape-4k" })).toBe(2);
});
it("returns 2 for portrait 1080p → portrait-4k", () => {
expect(
resolveDeviceScaleFactor({
...defaults,
compositionWidth: 1080,
compositionHeight: 1920,
outputResolution: "portrait-4k",
}),
).toBe(2);
});
it("returns 1 when the composition already matches the requested resolution", () => {
expect(
resolveDeviceScaleFactor({
compositionWidth: 3840,
compositionHeight: 2160,
outputResolution: "landscape-4k",
hdrRequested: false,
}),
).toBe(1);
});
it("rejects HDR + outputResolution with a clear message", () => {
expect(() =>
resolveDeviceScaleFactor({
...defaults,
outputResolution: "landscape-4k",
hdrRequested: true,
}),
).toThrow(/hdrMode='force-hdr'/);
});
it("rejects orientation mismatch (landscape comp → portrait-4k)", () => {
expect(() =>
resolveDeviceScaleFactor({ ...defaults, outputResolution: "portrait-4k" }),
).toThrow(/aspect ratio/);
});
it("rejects downsampling (4K composition → 1080p output)", () => {
expect(() =>
resolveDeviceScaleFactor({
compositionWidth: 3840,
compositionHeight: 2160,
outputResolution: "landscape",
hdrRequested: false,
}),
).toThrow(/Downsampling/);
});
it("rejects non-integer scale factors", () => {
// 1280×720 → 3840×2160 would be 3×, but width 1280 → 3840 is also 3× — that's actually integer.
// Use 1280×720 → 2160×3840 (mismatched orientation triggers aspect first), so use a real
// non-integer: 1500×844 → 3840×2160 = 2.56×.
expect(() =>
resolveDeviceScaleFactor({
compositionWidth: 1500,
compositionHeight: 844,
outputResolution: "landscape-4k",
hdrRequested: false,
}),
).toThrow(/aspect ratio|non-integer/);
});
});
@@ -29,6 +29,7 @@ import {
symlinkSync,
} from "fs";
import { parseHTML } from "linkedom";
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core";
import {
type EngineConfig,
resolveConfig,
@@ -279,6 +280,24 @@ export interface RenderConfig {
* `--variables-file <path>`. Must be a JSON-serializable plain object.
*/
variables?: Record<string, unknown>;
/**
* Override the output resolution. The composition's intrinsic
* `data-width` / `data-height` continue to drive page layout (Chrome
* viewport), and supersampling is achieved by setting Chrome's
* `deviceScaleFactor` so the captured screenshot lands at the requested
* dimensions. Passing a 4K preset on a 1080p composition therefore
* produces a 4K output without rewriting any composition HTML.
*
* Constraint: the requested dimensions must be an integer multiple of
* the composition's intrinsic dimensions (so DPR is a clean integer).
* Non-integer scales are rejected with an explanatory error before any
* frames are captured.
*
* Not yet supported with HDR (the layered HDR compositor processes
* pixel buffers at composition dimensions and would need parallel
* scaling); the orchestrator errors when both are set.
*/
outputResolution?: CanvasResolution;
}
export interface RenderPerfSummary {
@@ -563,6 +582,68 @@ export function projectBrowserEndToCompositionTimeline(
return browserEnd + (existingStart - browserStart);
}
/**
* Translate the user-facing `--resolution` flag into a Chrome
* `deviceScaleFactor`. The composition's intrinsic dimensions stay the
* page-layout viewport; the screenshot lands at output dims via DPR.
*
* The scale must be a positive integer ≥ 1 — fractional DPRs introduce
* visible aliasing and we'd rather fail loudly than produce a blurry
* 4K render. Downsampling (output < composition) is rejected because
* the user is unlikely to have intended it; if the use case appears
* we can plumb a separate flag.
*
* Throws on:
* - HDR + outputResolution combination (HDR layered compositor would
* need parallel scaling for its raw pixel buffers).
* - Non-integer scale (e.g. 720p composition, 4K output → 3× height
* but the width ratio is also 3× ✓; 1080p portrait → 4K landscape
* would mismatch).
* - Output dimensions smaller than composition dimensions.
*/
export function resolveDeviceScaleFactor(input: {
compositionWidth: number;
compositionHeight: number;
outputResolution: CanvasResolution | undefined;
hdrRequested: boolean;
}): number {
if (!input.outputResolution) return 1;
if (input.hdrRequested) {
throw new Error(
"outputResolution cannot be combined with hdrMode='force-hdr'. " +
"HDR rendering composites at composition dimensions and does not yet " +
"support supersampling. Pick one or render in two passes.",
);
}
const target = CANVAS_DIMENSIONS[input.outputResolution];
const widthRatio = target.width / input.compositionWidth;
const heightRatio = target.height / input.compositionHeight;
if (widthRatio !== heightRatio) {
throw new Error(
`outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` +
`does not match the aspect ratio of the composition ` +
`(${input.compositionWidth}×${input.compositionHeight}). ` +
`Pick a preset whose orientation matches.`,
);
}
if (widthRatio < 1) {
throw new Error(
`outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` +
`is smaller than the composition (${input.compositionWidth}×${input.compositionHeight}). ` +
`Downsampling via --resolution is not supported.`,
);
}
if (!Number.isInteger(widthRatio)) {
throw new Error(
`outputResolution ${input.outputResolution} requires a non-integer ` +
`device scale factor (${widthRatio}×) to upsample from ` +
`${input.compositionWidth}×${input.compositionHeight}. ` +
`Pick a preset that's an integer multiple, or rescale the composition.`,
);
}
return widthRatio;
}
function updateJobStatus(
job: RenderJob,
status: RenderStatus,
@@ -2053,6 +2134,22 @@ export async function executeRenderJob(
height: compiled.height,
};
const { width, height } = composition;
const deviceScaleFactor = resolveDeviceScaleFactor({
compositionWidth: width,
compositionHeight: height,
outputResolution: job.config.outputResolution,
hdrRequested: job.config.hdrMode === "force-hdr",
});
if (deviceScaleFactor > 1) {
log.info("Supersampling composition via deviceScaleFactor", {
compositionWidth: width,
compositionHeight: height,
outputResolution: job.config.outputResolution,
outputWidth: width * deviceScaleFactor,
outputHeight: height * deviceScaleFactor,
deviceScaleFactor,
});
}
const probeStart = Date.now();
const needsBrowser = composition.duration <= 0 || compiled.unresolvedCompositions.length > 0;
@@ -2077,6 +2174,7 @@ export async function executeRenderJob(
fps: job.config.fps,
format: needsAlpha ? "png" : "jpeg",
quality: needsAlpha ? undefined : 80,
deviceScaleFactor,
};
probeSession = await createCaptureSession(
fileServer.url,
@@ -2543,6 +2641,7 @@ export async function executeRenderJob(
format: needsAlpha ? "png" : "jpeg",
quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95,
variables: job.config.variables,
deviceScaleFactor,
};
// Capture sessions do not need native browser metadata for videos whose
@@ -3915,7 +4014,7 @@ export async function executeRenderJob(
chunkSizeFrames: enableChunkedEncode ? chunkedEncodeSize : null,
compositionDurationSeconds: composition.duration,
totalFrames: totalFrames,
resolution: { width, height },
resolution: { width: width * deviceScaleFactor, height: height * deviceScaleFactor },
videoCount: composition.videos.length,
audioCount: composition.audios.length,
stages: perfStages,