mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -15,7 +15,7 @@ inputs:
|
|||||||
behavior under us. The asset filename embeds the git hash, so both
|
behavior under us. The asset filename embeds the git hash, so both
|
||||||
the tag and the filename must be bumped together when upgrading.
|
the tag and the filename must be bumped together when upgrading.
|
||||||
required: false
|
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:
|
max-attempts:
|
||||||
description: Max download attempts before failing.
|
description: Max download attempts before failing.
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
"guides/deploy",
|
"guides/deploy",
|
||||||
"guides/remove-background",
|
"guides/remove-background",
|
||||||
"guides/hdr",
|
"guides/hdr",
|
||||||
|
"guides/4k-rendering",
|
||||||
"guides/performance",
|
"guides/performance",
|
||||||
"guides/timeline-editing",
|
"guides/timeline-editing",
|
||||||
"guides/video-editor-cheatsheet",
|
"guides/video-editor-cheatsheet",
|
||||||
|
|||||||
@@ -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**: 3–4× slower (Chrome paints 4× the pixels and the screenshot transfer is 4× larger)
|
||||||
|
- **Encoding**: 2–3× 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 3–5× 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
|
||||||
@@ -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) |
|
| `--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` |
|
| `--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` |
|
| `--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) |
|
| `--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 |
|
| `--sdr` | — | off | Force SDR output even if HDR sources are detected |
|
||||||
| `--workers` | 1-8 | 4 | Parallel render workers |
|
| `--workers` | 1-8 | 4 | Parallel render workers |
|
||||||
|
|||||||
@@ -207,6 +207,35 @@ describe("renderLocal browser GPU config", () => {
|
|||||||
expect(producerState.createdJobs[0]?.entryFile).toBeUndefined();
|
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 () => {
|
it("can force the CLI process to exit after a successful local render", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const exit = vi
|
const exit = vi
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync }
|
|||||||
export const examples: Example[] = [
|
export const examples: Example[] = [
|
||||||
["Render to MP4", "hyperframes render --output output.mp4"],
|
["Render to MP4", "hyperframes render --output output.mp4"],
|
||||||
["Render a specific composition", "hyperframes render -c compositions/intro.html -o intro.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 overlay (ProRes)", "hyperframes render --format mov --output overlay.mov"],
|
||||||
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
|
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
|
||||||
[
|
[
|
||||||
@@ -47,8 +51,35 @@ import {
|
|||||||
validateVariables,
|
validateVariables,
|
||||||
formatVariableValidationIssue,
|
formatVariableValidationIssue,
|
||||||
type VariableValidationIssue,
|
type VariableValidationIssue,
|
||||||
|
type CanvasResolution,
|
||||||
} from "@hyperframes/core";
|
} 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_FPS = new Set([24, 30, 60]);
|
||||||
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
|
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
|
||||||
const VALID_FORMAT = new Set(["mp4", "webm", "mov", "png-sequence"]);
|
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.",
|
"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,
|
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 }) {
|
async run({ args }) {
|
||||||
// ── Resolve project ────────────────────────────────────────────────────
|
// ── Resolve project ────────────────────────────────────────────────────
|
||||||
@@ -206,6 +242,31 @@ export default defineCommand({
|
|||||||
}
|
}
|
||||||
const format = formatRaw as "mp4" | "webm" | "mov" | "png-sequence";
|
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 ──────────────────────────────────────────────────
|
// ── Validate workers ──────────────────────────────────────────────────
|
||||||
let workers: number | undefined;
|
let workers: number | undefined;
|
||||||
if (args.workers != null && args.workers !== "auto") {
|
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),
|
c.accent("\u25C6") + " Rendering " + c.accent(nameLabel) + c.dim(" \u2192 " + outputPath),
|
||||||
);
|
);
|
||||||
console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel));
|
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") {
|
if (useGpu || browserGpuMode !== "software") {
|
||||||
const gpuModes = [
|
const gpuModes = [
|
||||||
useGpu ? "encoder GPU" : null,
|
useGpu ? "encoder GPU" : null,
|
||||||
@@ -452,6 +520,7 @@ export default defineCommand({
|
|||||||
quiet,
|
quiet,
|
||||||
variables,
|
variables,
|
||||||
entryFile,
|
entryFile,
|
||||||
|
outputResolution,
|
||||||
exitAfterComplete: true,
|
exitAfterComplete: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -469,6 +538,7 @@ export default defineCommand({
|
|||||||
browserPath,
|
browserPath,
|
||||||
variables,
|
variables,
|
||||||
entryFile,
|
entryFile,
|
||||||
|
outputResolution,
|
||||||
exitAfterComplete: true,
|
exitAfterComplete: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -495,6 +565,13 @@ interface RenderOptions {
|
|||||||
variables?: Record<string, unknown>;
|
variables?: Record<string, unknown>;
|
||||||
entryFile?: string;
|
entryFile?: string;
|
||||||
exitAfterComplete?: boolean;
|
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 =
|
export type VariablesParseError =
|
||||||
@@ -788,6 +865,7 @@ async function renderDocker(
|
|||||||
quiet: options.quiet,
|
quiet: options.quiet,
|
||||||
variables: options.variables,
|
variables: options.variables,
|
||||||
entryFile: options.entryFile,
|
entryFile: options.entryFile,
|
||||||
|
outputResolution: options.outputResolution,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -859,6 +937,7 @@ export async function renderLocal(
|
|||||||
videoBitrate: options.videoBitrate,
|
videoBitrate: options.videoBitrate,
|
||||||
variables: options.variables,
|
variables: options.variables,
|
||||||
entryFile: options.entryFile,
|
entryFile: options.entryFile,
|
||||||
|
outputResolution: options.outputResolution,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onProgress = options.quiet
|
const onProgress = options.quiet
|
||||||
|
|||||||
@@ -239,4 +239,19 @@ describe("buildDockerRunArgs", () => {
|
|||||||
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
|
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
|
||||||
expect(args).not.toContain("--composition");
|
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;
|
quiet: boolean;
|
||||||
variables?: Record<string, unknown>;
|
variables?: Record<string, unknown>;
|
||||||
entryFile?: string;
|
entryFile?: string;
|
||||||
|
/** Output resolution preset (e.g. "landscape-4k"). Forwarded as `--resolution`. */
|
||||||
|
outputResolution?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
|
export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
|
||||||
@@ -69,5 +71,6 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
|
|||||||
? ["--variables", JSON.stringify(options.variables)]
|
? ["--variables", JSON.stringify(options.variables)]
|
||||||
: []),
|
: []),
|
||||||
...(options.entryFile ? ["--composition", options.entryFile] : []),
|
...(options.entryFile ? ["--composition", options.entryFile] : []),
|
||||||
|
...(options.outputResolution ? ["--resolution", options.outputResolution] : []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
isRecoverableParallelCaptureError,
|
isRecoverableParallelCaptureError,
|
||||||
materializeExtractedFramesForCompiledDir,
|
materializeExtractedFramesForCompiledDir,
|
||||||
projectBrowserEndToCompositionTimeline,
|
projectBrowserEndToCompositionTimeline,
|
||||||
|
resolveDeviceScaleFactor,
|
||||||
resolveRenderWorkerCount,
|
resolveRenderWorkerCount,
|
||||||
resolveCompositeTransfer,
|
resolveCompositeTransfer,
|
||||||
selectCaptureCalibrationFrames,
|
selectCaptureCalibrationFrames,
|
||||||
@@ -749,3 +750,82 @@ describe("projectBrowserEndToCompositionTimeline", () => {
|
|||||||
expect(projectBrowserEndToCompositionTimeline(21.5, 1.5, 5.5)).toBe(25.5);
|
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,
|
symlinkSync,
|
||||||
} from "fs";
|
} from "fs";
|
||||||
import { parseHTML } from "linkedom";
|
import { parseHTML } from "linkedom";
|
||||||
|
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core";
|
||||||
import {
|
import {
|
||||||
type EngineConfig,
|
type EngineConfig,
|
||||||
resolveConfig,
|
resolveConfig,
|
||||||
@@ -279,6 +280,24 @@ export interface RenderConfig {
|
|||||||
* `--variables-file <path>`. Must be a JSON-serializable plain object.
|
* `--variables-file <path>`. Must be a JSON-serializable plain object.
|
||||||
*/
|
*/
|
||||||
variables?: Record<string, unknown>;
|
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 {
|
export interface RenderPerfSummary {
|
||||||
@@ -563,6 +582,72 @@ export function projectBrowserEndToCompositionTimeline(
|
|||||||
return browserEnd + (existingStart - browserStart);
|
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(
|
function updateJobStatus(
|
||||||
job: RenderJob,
|
job: RenderJob,
|
||||||
status: RenderStatus,
|
status: RenderStatus,
|
||||||
@@ -2053,6 +2138,22 @@ export async function executeRenderJob(
|
|||||||
height: compiled.height,
|
height: compiled.height,
|
||||||
};
|
};
|
||||||
const { width, height } = composition;
|
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 probeStart = Date.now();
|
||||||
const needsBrowser = composition.duration <= 0 || compiled.unresolvedCompositions.length > 0;
|
const needsBrowser = composition.duration <= 0 || compiled.unresolvedCompositions.length > 0;
|
||||||
@@ -2077,6 +2178,7 @@ export async function executeRenderJob(
|
|||||||
fps: job.config.fps,
|
fps: job.config.fps,
|
||||||
format: needsAlpha ? "png" : "jpeg",
|
format: needsAlpha ? "png" : "jpeg",
|
||||||
quality: needsAlpha ? undefined : 80,
|
quality: needsAlpha ? undefined : 80,
|
||||||
|
deviceScaleFactor,
|
||||||
};
|
};
|
||||||
probeSession = await createCaptureSession(
|
probeSession = await createCaptureSession(
|
||||||
fileServer.url,
|
fileServer.url,
|
||||||
@@ -2543,6 +2645,7 @@ export async function executeRenderJob(
|
|||||||
format: needsAlpha ? "png" : "jpeg",
|
format: needsAlpha ? "png" : "jpeg",
|
||||||
quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95,
|
quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95,
|
||||||
variables: job.config.variables,
|
variables: job.config.variables,
|
||||||
|
deviceScaleFactor,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture sessions do not need native browser metadata for videos whose
|
// Capture sessions do not need native browser metadata for videos whose
|
||||||
@@ -3915,7 +4018,7 @@ export async function executeRenderJob(
|
|||||||
chunkSizeFrames: enableChunkedEncode ? chunkedEncodeSize : null,
|
chunkSizeFrames: enableChunkedEncode ? chunkedEncodeSize : null,
|
||||||
compositionDurationSeconds: composition.duration,
|
compositionDurationSeconds: composition.duration,
|
||||||
totalFrames: totalFrames,
|
totalFrames: totalFrames,
|
||||||
resolution: { width, height },
|
resolution: { width: width * deviceScaleFactor, height: height * deviceScaleFactor },
|
||||||
videoCount: composition.videos.length,
|
videoCount: composition.videos.length,
|
||||||
audioCount: composition.audios.length,
|
audioCount: composition.audios.length,
|
||||||
stages: perfStages,
|
stages: perfStages,
|
||||||
|
|||||||
Reference in New Issue
Block a user