mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(cli): add --resolution flag to hyperframes render for one-line 4k
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user