From e07aeba213891c64d9586b71ba19a8b52dfd7a61 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 7 May 2026 03:26:14 +0000 Subject: [PATCH] feat(cli): add --resolution flag to hyperframes render for one-line 4k --- docs/docs.json | 1 + docs/guides/4k-rendering.mdx | 138 ++++++++++++++++++ docs/packages/cli.mdx | 1 + packages/cli/src/commands/render.ts | 63 ++++++++ packages/cli/src/utils/dockerRunArgs.test.ts | 15 ++ packages/cli/src/utils/dockerRunArgs.ts | 3 + .../src/services/renderOrchestrator.test.ts | 80 ++++++++++ .../src/services/renderOrchestrator.ts | 101 ++++++++++++- 8 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 docs/guides/4k-rendering.mdx diff --git a/docs/docs.json b/docs/docs.json index 314a74f98..025e5600b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -80,6 +80,7 @@ "guides/deploy", "guides/remove-background", "guides/hdr", + "guides/4k-rendering", "guides/performance", "guides/timeline-editing", "guides/video-editor-cheatsheet", diff --git a/docs/guides/4k-rendering.mdx b/docs/guides/4k-rendering.mdx new file mode 100644 index 000000000..c8346ccff --- /dev/null +++ b/docs/guides/4k-rendering.mdx @@ -0,0 +1,138 @@ +--- +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. + + + + 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 + ``` + + + 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 + ``` + + + +## Quickstart + + + + ```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. + + + ```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 `` tag. + + + ```bash Terminal + ffprobe -v error -select_streams v:0 -show_entries stream=width,height my-video-4k.mp4 + ``` + + Expected: + ``` + width=3840 + height=2160 + ``` + + + +## 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. Text, SVG, and images served at higher intrinsic resolutions all benefit from the upsample. Pre-rasterized assets (a 1920×1080 PNG, for example) won't gain detail they don't have but won't be visibly blurred either. + + + 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. + + +## 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 + +A resolution selector in Studio is on the way (tracked separately). For now, switch 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 diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index ca7df21e6..120bd7fc9 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -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 | diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index d0fbfb34d..574f9115a 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -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 = { + "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; 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 diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index b4f853ea3..981bc24c4 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -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"); + }); }); diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index a3d275b6b..8630d3bd4 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -31,6 +31,8 @@ export interface DockerRenderOptions { quiet: boolean; variables?: Record; 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] : []), ]; } diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 3245b637f..587004c04 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -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/); + }); +}); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 9e700fc33..de8aa78d2 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -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 `. Must be a JSON-serializable plain object. */ variables?: Record; + /** + * 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,