Merge pull request #663 from heygen-com/05-07-feat_cli_add_--resolution_flag_to_hyperframes_render_for_one-line_4k

feat(cli): add --resolution flag to hyperframes render for one-line 4k
This commit is contained in:
James Russo
2026-05-07 10:40:59 -07:00
committed by GitHub
10 changed files with 472 additions and 2 deletions
@@ -15,7 +15,7 @@ inputs:
behavior under us. The asset filename embeds the git hash, so both
the tag and the filename must be bumped together when upgrading.
required: false
default: https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-04-23-13-16/ffmpeg-N-124085-g162ad61486-win64-gpl.zip
default: https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-04-30-13-44/ffmpeg-N-124278-gcc3ca17127-win64-gpl.zip
max-attempts:
description: Max download attempts before failing.
required: false
+1
View File
@@ -80,6 +80,7 @@
"guides/deploy",
"guides/remove-background",
"guides/hdr",
"guides/4k-rendering",
"guides/performance",
"guides/timeline-editing",
"guides/video-editor-cheatsheet",
+159
View File
@@ -0,0 +1,159 @@
---
title: 4K Rendering
description: "Render any composition to 4K (3840×2160) without rewriting it — the CLI supersamples a 1080p composition via Chrome's device scale factor."
---
Hyperframes renders to 4K (3840×2160) two ways. Both produce a true 4K MP4; pick the one that matches your project.
<CardGroup cols={2}>
<Card title="Author at 4K" icon="ruler">
Scaffold the project at 4K so the composition is laid out at 4K natively. Best when you want crisp 4K-native typography and assets.
```bash
npx hyperframes init my-video --resolution 4k
```
</Card>
<Card title="Supersample at render" icon="up-right-and-down-left-from-center">
Keep your existing 1080p composition. Pass `--resolution 4k` at render time and Chrome renders at 2× DPR so the screenshot lands at 4K.
```bash
npx hyperframes render --resolution 4k --output 4k.mp4
```
</Card>
</CardGroup>
## Quickstart
<Steps>
<Step title="Render an existing project at 4K">
```bash Terminal
npx hyperframes render --resolution 4k --output my-video-4k.mp4
```
The composition's `data-width` / `data-height` are unchanged. Chrome's `deviceScaleFactor` is set to `2`, so the captured screenshot for each frame is 3840×2160. ffmpeg auto-detects the dimensions from the screenshot stream and encodes at 4K.
</Step>
<Step title="Or scaffold a new project at 4K">
```bash Terminal
npx hyperframes init my-video --resolution 4k
```
Every scaffolded HTML file is patched in place: `data-width="3840"`, `data-height="2160"`, `data-resolution="landscape-4k"`, `#stage` CSS dimensions, and the `<meta viewport>` tag.
</Step>
<Step title="Verify the output is 4K">
```bash Terminal
ffprobe -v error -select_streams v:0 -show_entries stream=width,height my-video-4k.mp4
```
Expected:
```
width=3840
height=2160
```
</Step>
</Steps>
## Resolution presets
`--resolution` accepts these values on both `init` and `render`:
| Preset | Dimensions | Aliases |
|--------|-----------|---------|
| `landscape` | 1920×1080 | `1080p`, `hd` |
| `portrait` | 1080×1920 | `1080p-portrait` |
| `landscape-4k` | 3840×2160 | `4k`, `uhd` |
| `portrait-4k` | 2160×3840 | `4k-portrait` |
Examples:
```bash Terminal
npx hyperframes render --resolution 4k # landscape 4K
npx hyperframes render --resolution portrait-4k # vertical 4K (TikTok / Reels at max quality)
npx hyperframes render --resolution 1080p # explicit 1080p (no-op on 1080p compositions)
```
## How `--resolution` works (supersampling)
The composition stays at its authored dimensions. Hyperframes computes a `deviceScaleFactor` from the ratio of output to composition dimensions and passes it to Chrome:
| Composition | `--resolution` | `deviceScaleFactor` | Output |
|-------------|---------------|--------------------|--------|
| 1920×1080 | `4k` | 2 | 3840×2160 |
| 1080×1920 | `portrait-4k` | 2 | 2160×3840 |
| 3840×2160 | `4k` | 1 (no-op) | 3840×2160 |
Chrome then renders the page at the higher DPR — effectively rendering each CSS pixel as 2×2 device pixels — so the captured screenshot is at the requested resolution.
<Tip>
This approach is intentionally simple — no composition edits, no second authoring pass. The tradeoff: 4K renders take roughly 4× as long per frame because there are 4× the pixels to capture and encode.
</Tip>
## What scales, what doesn't
Supersampling re-renders the page at higher DPR. That genuinely helps anything the browser rasterizes from a vector or high-resolution source, and does nothing for content already locked to a fixed pixel grid. Knowing which is which sets correct expectations before a 4K render:
| Asset type | Behavior at `--resolution 4k` |
|------------|------------------------------|
| Text (HTML, SVG `<text>`, web fonts) | ✅ **Re-rasterized at 4K.** Glyphs are vector and the browser shapes/rasterizes them at the new DPR. Crisp at any scale. |
| SVG / vector graphics | ✅ **Re-rasterized at 4K.** Same story as text — paths are vector. |
| CSS shapes, gradients, borders, shadows | ✅ **Re-rasterized at 4K.** Browser-generated raster. |
| Images with intrinsic dimensions ≥ 4K | ✅ **Full benefit.** A 3840×2160 source serves all the detail. |
| Images smaller than 4K (e.g. a 1920×1080 PNG) | ⚠️ **No new detail.** Browser upscales the source bitmap; output is no sharper than rendering at 1080p and upscaling externally — but no worse either. |
| `<video>` elements | ❌ **Locked to source resolution.** A 1080p MP4 stays 1080p; the supersample only helps the surrounding DOM. Encode source video at the target resolution if you need 4K throughout. |
| `<canvas>` (2D and WebGL) | ❌ **Locked to canvas's intrinsic dimensions.** `<canvas width="1920" height="1080">` is a 1080p bitmap regardless of DPR. To render canvas content at 4K, multiply `canvas.width` / `canvas.height` by your target DPR and scale the drawing context (`ctx.scale(2, 2)` for a 2× canvas with the same logical layout). |
| Pre-rendered video frames injected by the engine | ❌ **Locked to extraction resolution.** When the producer pre-extracts `<video>` frames via ffmpeg, they're decoded at the source video's dimensions. |
**Rule of thumb**: if the asset is *vector or generated by the browser*, supersampling helps. If it's a *bitmap with fixed pixel dimensions* (video, canvas, low-res PNG), it doesn't — author it at the target resolution instead.
## Constraints
`--resolution` enforces three guards before any frames are captured. If any fail, the render exits before doing work.
### Aspect ratio must match
```bash
# OK — both landscape
hyperframes render --resolution 4k # composition is 1920×1080
# Error — composition is landscape, target is portrait
hyperframes render --resolution portrait-4k # composition is 1920×1080
# → outputResolution portrait-4k (2160×3840) does not match the aspect ratio
# of the composition (1920×1080). Pick a preset whose orientation matches.
```
### The scale must be an integer
The width ratio (output ÷ composition) must be a positive integer. 1080p → 4K is exactly `2×`. 720p → 4K would be `3×` and works. Non-integer scales like 900p → 4K (`2.4×`) introduce aliasing on subpixel-positioned text — Hyperframes refuses rather than producing a blurry render.
### Downsampling is not supported
`--resolution` only supersamples. A 4K composition cannot be downsampled to 1080p with this flag — render at the composition's native resolution and downscale separately with ffmpeg if needed.
### Not yet supported with `--hdr`
The HDR layered compositor processes pixel buffers at composition dimensions; supersample + HDR would need parallel scaling for those buffers. The combination is rejected with a clear error message. Render in two passes if you need both: HDR at composition resolution, then upscale separately.
## Performance
A 1080p → 4K supersample is roughly 4× more pixels to capture, encode, and write. Expect:
- **Per-frame capture**: 34× slower (Chrome paints 4× the pixels and the screenshot transfer is 4× larger)
- **Encoding**: 23× slower (depends on codec; H.264 scales sublinearly with resolution)
- **Memory**: bounded — the engine's frame data-URI cache is byte-budgeted (default 1500 MB per worker, configurable via `PRODUCER_FRAME_DATA_URI_CACHE_BYTES_MB`)
- **Output file size**: at the default CRF, expect 35× the file size of the 1080p render. Pass `--video-bitrate 25M` (or higher) for predictable file sizes.
For a 4K render of a 30-second composition, plan on a few minutes of wall time on a modern laptop. Add `--workers 4` (or more) on a render box for parallel capture.
## Studio support
The Renders panel in Studio includes a resolution dropdown next to the format and quality selectors. Pick `4K` (or `4K ↕` for portrait) and hit **Export** — the same supersampling path runs as the CLI flag, no composition edits required.
The dropdown defaults to `Auto` (render at the composition's authored size). The resolution applies per render, not per project — your composition files are unchanged.
You can also drive resolution from the CLI:
- **New project**: `hyperframes init my-video --resolution 4k`
- **Existing project**: `hyperframes render --resolution 4k --output 4k.mp4`
## See also
- [`render` CLI reference](/packages/cli#render) — every render flag including `--video-bitrate` and `--crf`
- [`init` CLI reference](/packages/cli#init) — the `--resolution` flag at scaffold time
- [HDR Rendering](/guides/hdr) — color pipeline guide; HDR + 4K is not yet a supported combination
+1
View File
@@ -610,6 +610,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
| `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) |
| `--crf` | 0-51 | — | Override encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate` |
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target video bitrate. Mutually exclusive with `--crf` |
| `--resolution` | landscape, portrait, landscape-4k, portrait-4k (aliases: `1080p`, `4k`, `uhd`) | — | Output resolution preset. Supersamples a smaller composition via Chrome `deviceScaleFactor` so the screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not supported with `--hdr`. See [4K Rendering](/guides/4k-rendering) |
| `--hdr` | — | off | Force HDR output even if no HDR sources are detected. MP4 only. See [HDR Rendering](/guides/hdr) |
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
| `--workers` | 1-8 | 4 | Parallel render workers |
+29
View File
@@ -207,6 +207,35 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.entryFile).toBeUndefined();
});
it("forwards outputResolution to createRenderJob when --resolution is set", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
outputResolution: "landscape-4k",
});
expect(producerState.createdJobs[0]?.outputResolution).toBe("landscape-4k");
});
it("omits outputResolution from createRenderJob by default", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
expect(producerState.createdJobs[0]?.outputResolution).toBeUndefined();
});
it("can force the CLI process to exit after a successful local render", async () => {
vi.useFakeTimers();
const exit = vi
+79
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,31 @@ 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);
}
// Reject the --resolution + --hdr combination at the CLI layer so the
// user sees the friendly errorBox before any work directories or
// ffmpeg processes spin up. The orchestrator also enforces this via
// resolveDeviceScaleFactor — defense in depth.
if (args.hdr) {
errorBox(
"Conflicting flags",
"--resolution cannot be combined with --hdr. The HDR pipeline composites at composition dimensions and does not yet support supersampling.",
"Render in two passes: HDR at composition resolution, then upscale separately with ffmpeg.",
);
process.exit(1);
}
}
// ── Validate workers ──────────────────────────────────────────────────
let workers: number | undefined;
if (args.workers != null && args.workers !== "auto") {
@@ -319,6 +380,13 @@ 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) {
// Don't claim "supersampled" — when the composition is already at the
// target dimensions, the DPR resolves to 1 and no supersampling
// happens. We don't have the composition's dims at this point in the
// CLI, so describe the intent rather than the mechanism.
console.log(c.dim(" Output resolution: " + outputResolution));
}
if (useGpu || browserGpuMode !== "software") {
const gpuModes = [
useGpu ? "encoder GPU" : null,
@@ -452,6 +520,7 @@ export default defineCommand({
quiet,
variables,
entryFile,
outputResolution,
exitAfterComplete: true,
});
} else {
@@ -469,6 +538,7 @@ export default defineCommand({
browserPath,
variables,
entryFile,
outputResolution,
exitAfterComplete: true,
});
}
@@ -495,6 +565,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 +865,7 @@ async function renderDocker(
quiet: options.quiet,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
},
});
@@ -859,6 +937,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,72 @@ 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];
// Aspect-ratio compare via cross-multiplication so the equality is integer-
// safe. Float division (`target.width / compositionWidth`) loses precision
// for non-power-of-2 ratios (e.g. cinema 4K 4096×2160 = 1.8963…) and a
// future preset could trip a false-mismatch on otherwise valid input.
if (target.width * input.compositionHeight !== target.height * input.compositionWidth) {
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.`,
);
}
// Aspect ratios match → widthRatio === heightRatio. Compute once.
const widthRatio = target.width / input.compositionWidth;
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 +2138,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 +2178,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 +2645,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 +4018,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,