Skip to content

Commit 09c0307

Browse files
feat(producer): render at 720p and 480p from a larger composition (#5724)
## What a user can do now Render a video at 720p or 480p: `hyperframes render --resolution 720p` (or `480p`, or the presets `landscape-720p`, `portrait-480p`, `square-720p` and so on) gives a 1280×720 or 854×480 file from a 1080p composition, and the same presets work through the studio-server render route. Before, any preset smaller than the composition was refused ("Downsampling via --resolution is not supported"). ## How - `parsers/src/types.ts`: six presets, `{landscape,portrait,square}-{720p,480p}`, and the `720p` / `480p` aliases (aspect-agnostic, like `1080p` and `4k`). 480p at 16:9 is 854×480: 480 × 16/9 is 853.33, and 4:2:0 encoders need even sides. - `checkOutputResolutionCompatibility` now allows a preset smaller than the composition. A smaller preset may be off by under one pixel on its long side (the 854); a larger one must still match exactly and be a whole multiple, as before. The aspect remap that `--resolution 1080p` / `4k` apply on their own (`suggestMatchingPreset`, used by the CLI preflight and the compile stage) returns only a preset in the asked tier, never another: with the looser match for smaller presets, a 1922×1080 composition asked for `4k` would otherwise have been remapped to 480p. It is refused instead, as before. - `resolveDeviceScaleFactor` returns the long side's ratio, so a smaller preset renders at a device scale factor under 1: Chrome lays the page out at composition size and rasterizes each frame straight at 1280×720 or 854×480, the way 4K already renders at 2. For 480p that is 854/1920: the width lands on 854 exactly and the height's 480.375 rounds to 480, so no pad column is added. Output sizes round as Chrome rounds the screenshot. - The streaming encoder was given the composition's size instead of the output size on both streaming paths (`renderOrchestrator.ts`). 4K never noticed, as both sizes are even; at 480p the encoder skipped its even-size pad and libx264 refused an odd frame (`width not divisible by 2 (853x480)`). It now gets the output size, as the disk-frame encode already did. - The paths that refused DPR > 1 (BeginFrame, drawElement, the Linux fast-capture gate) now refuse any DPR other than 1, since none of them honor a scale; scaled renders take the screenshot path, as 4K does. - `render/outputScaleRoute.ts`: the layered compositor (HDR content, and shader transitions in webm and mov) sizes its layers and encoder from the composition and never scales, so a scaled output on that route now stops with a named error, checked where every route and replan is settled (`syncCapturePlan`, beside motion blur's route check). Before, a 1080p request from a 4K composition with shader transitions as mov would have encoded 3840×2160. HDR with `--resolution` was already refused in preflight; 4K on this route had the same gap on main and gets the same error. ## What I measured - Real renders from source on a Linux box (`bun src/cli.ts render registry/examples/nyt-graph --resolution 720p|480p|1080p`), checked with ffprobe: 720p `h264, 1280x720, yuv420p, 15.0 s`; 480p `h264, 854x480, yuv420p, 15.0 s`; 1080p unchanged at 1920x1080. A frame of each, looked at: same chart and layout, crisp text, and the 480p right-edge column is the page's own color (250,248,242), not a pad. - Before the encoder-size fix the 480p render failed with `[libx264] width not divisible by 2 (853x480)`; after it, it renders. - `outputScaleRoute.test.ts`: the real route choice for a 4K composition with shader transitions as mov (`outputSupportsPageSideShaderCompositing`, `shouldUseLayeredComposite`, `createCapturePlan`, `resolveDeviceScaleFactor`) lands on `hdr_layered` at scale 0.5 and is refused. CLI preflight and the compile stage both refuse `4k` on a 1922×1080 composition. - fails without the fix: without the same-tier rule, the preflight and compile tests for 1922×1080 at `4k` pass the render through at 854×480; without the route check, the mov test does not throw. - At d33c445: producer `outputScaleRoute` + `compileStage` 18/18 three runs in a row, `shared` (bun) 9/9, cli `render` 104/104 once and its new test three runs in a row; tsc clean in producer and cli. Earlier at e72f650: parsers `outputResolutionCompatibility` 20/20, core `index` 26/26, producer `resolveDeviceScaleFactor` 14/14, cli `render` + `parseOutputResolution` 122/122, studio-server `render` 53/53. The new cases fail on main (the presets don't exist; smaller presets are refused). tsc clean in parsers, engine, producer and cli; oxlint and oxfmt clean. ## What I did NOT exercise - Distributed (Lambda / Cloud Run) renders at 720p/480p: the chunk worker's sizes round the same way, but I did not run a distributed render. - Studio's render queue keeps its own copy of the rule (`RenderQueue.tsx` `scaleApplies`) and offers no 720p/480p option; unchanged here.
1 parent e6ce733 commit 09c0307

14 files changed

Lines changed: 243 additions & 81 deletions

File tree

‎packages/cli/src/commands/render.test.ts‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,10 +1871,10 @@ describe("checkRenderResolutionPreflight", () => {
18711871
expect(result?.kind).toBe("hdr-incompatible");
18721872
});
18731873

1874-
it("classifies a preset smaller than the composition as downsampling", async () => {
1875-
// 3840×2160 comp + landscape (1920×1080): same 16:9 aspect, target smaller.
1874+
it("passes a preset smaller than the composition", async () => {
1875+
// 3840×2160 comp + landscape (1920×1080): same 16:9 aspect, rendered at 0.5× DPR.
18761876
const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", noModes);
1877-
expect(result?.kind).toBe("downsampling");
1877+
expect(result).toBeUndefined();
18781878
});
18791879

18801880
it("classifies a non-integer upscale as non-integer-scale", async () => {
@@ -1944,12 +1944,18 @@ describe("checkRenderResolutionPreflight", () => {
19441944
expect(result?.kind).toBe("hdr-incompatible");
19451945
});
19461946

1947-
it("still flags downsampling + aspect-agnostic (same-orientation, smaller preset)", async () => {
1948-
// 3840×2160 comp with `--resolution 1080p` → `landscape` (1920×1080).
1949-
// Same orientation, but tier smaller than comp — user asked for a
1950-
// downsample. That's a real incompatibility, not an orientation swap.
1947+
it("passes `--resolution 1080p` on a 4K composition (a smaller, same-orientation preset)", async () => {
19511948
const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", agnostic);
1952-
expect(result?.kind).toBe("downsampling");
1949+
expect(result).toBeUndefined();
1950+
});
1951+
1952+
it("never adapts `--resolution 4k` to another tier: a 1922×1080 composition is refused, not rendered at 480p", async () => {
1953+
const result = await checkRenderResolutionPreflight(
1954+
comp(1922, 1080),
1955+
"landscape-4k",
1956+
agnostic,
1957+
);
1958+
expect(result?.kind).toBeDefined();
19531959
});
19541960

19551961
it("does NOT auto-clear when the flag was explicit (orientation-locked preset stays strict)", async () => {
@@ -1983,14 +1989,10 @@ describe("checkRenderResolutionPreflight", () => {
19831989
expect(result?.message).toMatch(/preset whose orientation matches|omit --resolution/i);
19841990
});
19851991

1986-
it("blocks a portrait-4K comp + `1080p` downsample early (orientation-flip masks the tier gap)", async () => {
1987-
// 2160×3840 (portrait 4K) + `--resolution 1080p` — the compile stage
1988-
// remaps `landscape` → `portrait` (1080×1920), and *then* the preset
1989-
// is smaller than the composition. The un-remapped preflight let this
1990-
// slip through as an aspect-mismatch downgrade; the remap-then-check
1991-
// catches the real failure — downsampling — early.
1992+
it("passes a portrait-4K comp + `1080p` once remapped to portrait (a smaller preset)", async () => {
1993+
// 2160×3840 + `--resolution 1080p` remaps `landscape` → `portrait` (1080×1920), rendered at 0.5× DPR.
19921994
const result = await checkRenderResolutionPreflight(comp(2160, 3840), "landscape", agnostic);
1993-
expect(result?.kind).toBe("downsampling");
1995+
expect(result).toBeUndefined();
19941996
});
19951997

19961998
it("blocks a portrait 720p comp + `1080p` non-integer upscale early (orientation-flip masks the fractional DPR)", async () => {

‎packages/core/src/index.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ describe("@hyperframes/core public API exports", () => {
2222
"portrait-4k",
2323
"square",
2424
"square-4k",
25+
"landscape-720p",
26+
"portrait-720p",
27+
"square-720p",
28+
"landscape-480p",
29+
"portrait-480p",
30+
"square-480p",
2531
]);
2632
});
2733

‎packages/engine/src/services/frameCapture.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -907,18 +907,18 @@ async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boole
907907
* screenshots carry a real alpha channel (Chrome resets the override on every
908908
* navigation, so this must run after page load).
909909
*
910-
* drawElement is also skipped when supersampling (deviceScaleFactor > 1):
910+
* drawElement is also skipped when scaling (deviceScaleFactor other than 1):
911911
* `drawElementImage` reads the canvas at CSS pixels and has no equivalent of
912912
* `Page.captureScreenshot`'s clip+scale, so it would silently capture at 1x and
913913
* drop the requested supersample. Such renders fall through to the screenshot
914-
* path (preMode already forces "screenshot" for DPR > 1).
914+
* path (preMode already forces "screenshot" for DPR other than 1).
915915
*/
916916
async function initDrawElementOrTransparentBackground(
917917
session: CaptureSession,
918918
page: Page,
919919
logInitPhase: (phase: string) => void,
920920
): Promise<void> {
921-
const supersampling = (session.options.deviceScaleFactor ?? 1) > 1;
921+
const supersampling = (session.options.deviceScaleFactor ?? 1) !== 1;
922922
// forceScreenshot is an explicit routing decision made upstream (render-mode
923923
// compat hints like raw requestAnimationFrame, alpha formats, low-memory) —
924924
// drawElement must not override it. Concretely: an rAF-compat comp on
@@ -948,7 +948,7 @@ async function initDrawElementOrTransparentBackground(
948948
session.deFallbackTrigger = "supersampling";
949949
console.log(
950950
"[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
951-
"ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.",
951+
"ignores deviceScaleFactor, so scaled (DPR other than 1) output uses screenshot capture.",
952952
);
953953
}
954954
if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
@@ -1286,8 +1286,8 @@ export async function createCaptureSession(
12861286
// (the captured surface is sized by the OS window in CSS pixels regardless
12871287
// of `Emulation.setDeviceMetricsOverride`'s DPR). When supersampling we
12881288
// need explicit clip+scale on `Page.captureScreenshot`, so fall back to
1289-
// the screenshot path for any DPR > 1.
1290-
const supersampling = (options.deviceScaleFactor ?? 1) > 1;
1289+
// the screenshot path for any DPR other than 1.
1290+
const supersampling = (options.deviceScaleFactor ?? 1) !== 1;
12911291
const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
12921292
const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
12931293
chromePath: headlessShell ?? undefined,

‎packages/parsers/src/outputResolutionCompatibility.test.ts‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,52 @@ describe("checkOutputResolutionCompatibility", () => {
116116
});
117117

118118
describe("scale constraints", () => {
119-
it("flags downsampling (preset smaller than composition)", () => {
120-
// 3840×2160 composition, landscape (1920×1080) preset → 0.5× DPR.
119+
it("allows a preset smaller than the composition (0.5× DPR)", () => {
121120
const result = checkOutputResolutionCompatibility({
122121
compositionWidth: 3840,
123122
compositionHeight: 2160,
124123
outputResolution: "landscape",
125124
});
126-
expect(result.ok).toBe(false);
127-
expect(result.kind).toBe("downsampling");
125+
expect(result.ok).toBe(true);
126+
});
127+
128+
it.each([
129+
[1920, 1080, "landscape-720p"],
130+
[1920, 1080, "landscape-480p"],
131+
[1080, 1920, "portrait-720p"],
132+
[1080, 1920, "portrait-480p"],
133+
[1080, 1080, "square-720p"],
134+
[1080, 1080, "square-480p"],
135+
] as const)(
136+
"allows %i×%i down to %s",
137+
(compositionWidth, compositionHeight, outputResolution) => {
138+
expect(
139+
checkOutputResolutionCompatibility({
140+
compositionWidth,
141+
compositionHeight,
142+
outputResolution,
143+
}),
144+
).toEqual({ ok: true });
145+
},
146+
);
147+
148+
it("keeps the 480p tier when suggesting a swap for the wrong orientation", () => {
149+
const result = checkOutputResolutionCompatibility({
150+
compositionWidth: 1920,
151+
compositionHeight: 1080,
152+
outputResolution: "portrait-480p",
153+
});
154+
expect(result.kind).toBe("aspect-mismatch");
155+
expect(result.suggestedResolution).toBe("landscape-480p");
156+
});
157+
158+
it("still refuses a near-16:9 composition a pixel too wide for an exact preset", () => {
159+
const result = checkOutputResolutionCompatibility({
160+
compositionWidth: 1921,
161+
compositionHeight: 1080,
162+
outputResolution: "landscape",
163+
});
164+
expect(result.kind).toBe("aspect-mismatch");
128165
});
129166

130167
it("flags a non-integer scale factor for a same-aspect custom composition", () => {

‎packages/parsers/src/outputResolutionCompatibility.ts‎

Lines changed: 27 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { CANVAS_DIMENSIONS, VALID_CANVAS_RESOLUTIONS, type CanvasResolution } fr
2828
export type OutputResolutionIssueKind =
2929
| "hdr-incompatible"
3030
| "aspect-mismatch"
31-
| "downsampling"
3231
| "non-integer-scale";
3332

3433
export interface OutputResolutionCompatibility {
@@ -69,22 +68,31 @@ export function suggestMatchingPreset(
6968
compositionHeight: number,
7069
chosen: CanvasResolution,
7170
): CanvasResolution | undefined {
72-
const aspectMatches: CanvasResolution[] = VALID_CANVAS_RESOLUTIONS.filter((preset) => {
73-
const { width, height } = CANVAS_DIMENSIONS[preset];
74-
// Integer-safe aspect compare (cross-multiplication), matching the
75-
// producer's mismatch check exactly.
76-
return width * compositionHeight === height * compositionWidth;
77-
});
71+
const aspectMatches: CanvasResolution[] = VALID_CANVAS_RESOLUTIONS.filter((preset) =>
72+
fitsAspect(CANVAS_DIMENSIONS[preset], compositionWidth, compositionHeight),
73+
);
7874
if (aspectMatches.length === 0) return undefined;
7975

80-
// Prefer the aspect-matching preset in the same resolution tier as the chosen
81-
// one so we don't silently downgrade 4K → HD (or vice versa). Tier is keyed
82-
// off the `-4k` suffix rather than long-side pixels: a 4K *square* (2160×2160)
83-
// has a shorter long side than 4K landscape (3840), so a pixel-based compare
84-
// would fail to recognise `square-4k` as the 4K peer and downgrade to `square`.
85-
const chosenIs4k = chosen.endsWith("-4k");
86-
const sameTier = aspectMatches.find((preset) => preset.endsWith("-4k") === chosenIs4k);
87-
return sameTier ?? aspectMatches[0];
76+
// Only the aspect-matching preset in the chosen tier, never another tier: callers apply this
77+
// automatically, and a 4K request must not become HD or 480p. Tier is keyed off the suffix
78+
// (`-4k`, `-720p`, …) rather than long-side pixels: a 4K *square* (2160×2160) has a shorter long
79+
// side than 4K landscape (3840), so a pixel-based compare would miss `square-4k` as the 4K peer.
80+
return aspectMatches.find((preset) => tierOf(preset) === tierOf(chosen));
81+
}
82+
83+
/** `landscape-4k` → `-4k`, `portrait` → `` (1080p). */
84+
const tierOf = (preset: CanvasResolution) =>
85+
preset.includes("-") ? preset.slice(preset.indexOf("-")) : "";
86+
87+
/** Integer-safe aspect compare. A smaller preset may be under a pixel off on its long side (854×480 at 16:9). */
88+
function fitsAspect(
89+
target: { width: number; height: number },
90+
compositionWidth: number,
91+
compositionHeight: number,
92+
): boolean {
93+
const drift = Math.abs(target.width * compositionHeight - target.height * compositionWidth);
94+
if (drift === 0) return true;
95+
return target.width < compositionWidth && drift < Math.min(compositionWidth, compositionHeight);
8896
}
8997

9098
function describeOrientation(width: number, height: number): string {
@@ -154,26 +162,14 @@ export function checkOutputResolutionCompatibility(input: {
154162
}
155163

156164
const target = CANVAS_DIMENSIONS[outputResolution];
157-
// Aspect-ratio compare via cross-multiplication so the equality is integer-
158-
// safe. Float division (`target.width / compositionWidth`) loses precision
159-
// for non-power-of-2 ratios (e.g. cinema 4K 4096×2160 = 1.8963…) and a
160-
// future preset could trip a false-mismatch on otherwise valid input.
161-
if (target.width * compositionHeight !== target.height * compositionWidth) {
165+
if (!fitsAspect(target, compositionWidth, compositionHeight)) {
162166
return buildAspectMismatch(compositionWidth, compositionHeight, outputResolution, target);
163167
}
164168

165-
// Aspect ratios match → widthRatio === heightRatio. Compute once.
169+
// A smaller preset renders at a device scale factor under 1: Chrome lays the
170+
// page out at composition size and rasterizes it straight at the preset's.
166171
const widthRatio = target.width / compositionWidth;
167-
if (widthRatio < 1) {
168-
return {
169-
ok: false,
170-
kind: "downsampling",
171-
message:
172-
`outputResolution ${outputResolution} (${target.width}×${target.height}) ` +
173-
`is smaller than the composition (${compositionWidth}×${compositionHeight}). ` +
174-
`Downsampling via --resolution is not supported.`,
175-
};
176-
}
172+
if (widthRatio < 1) return OK;
177173

178174
if (!Number.isInteger(widthRatio)) {
179175
return {

‎packages/parsers/src/types.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export const CANVAS_DIMENSIONS = {
2323
"portrait-4k": { width: 2160, height: 3840 },
2424
square: { width: 1080, height: 1080 },
2525
"square-4k": { width: 2160, height: 2160 },
26+
"landscape-720p": { width: 1280, height: 720 },
27+
"portrait-720p": { width: 720, height: 1280 },
28+
"square-720p": { width: 720, height: 720 },
29+
// 480 tall at 16:9 is 853.33 wide: 854 keeps both sides even for 4:2:0 encoders.
30+
"landscape-480p": { width: 854, height: 480 },
31+
"portrait-480p": { width: 480, height: 854 },
32+
"square-480p": { width: 480, height: 480 },
2633
} as const;
2734

2835
// Single source of truth: derive the type from the table so adding a preset
@@ -49,6 +56,12 @@ const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
4956
"1080p-square": "square",
5057
"square-1080p": "square",
5158
"4k-square": "square-4k",
59+
"720p": "landscape-720p",
60+
"720p-portrait": "portrait-720p",
61+
"720p-square": "square-720p",
62+
"480p": "landscape-480p",
63+
"480p-portrait": "portrait-480p",
64+
"480p-square": "square-480p",
5265
};
5366

5467
/**
@@ -70,6 +83,8 @@ const ASPECT_AGNOSTIC_RESOLUTION_ALIASES: ReadonlySet<string> = new Set([
7083
"hd",
7184
"4k",
7285
"uhd",
86+
"720p",
87+
"480p",
7388
]);
7489

7590
/**

‎packages/producer/src/services/distributed/renderChunk.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,8 +1072,8 @@ export async function renderChunk(
10721072
outputPath: outputChunkPath,
10731073
framesDir,
10741074
videoOnlyPath,
1075-
width: plan.dimensions.width * encoder.deviceScaleFactor,
1076-
height: plan.dimensions.height * encoder.deviceScaleFactor,
1075+
width: Math.round(plan.dimensions.width * encoder.deviceScaleFactor),
1076+
height: Math.round(plan.dimensions.height * encoder.deviceScaleFactor),
10771077
needsAlpha: plan.dimensions.format !== "mp4",
10781078
captureImageFormat: captureOptions.format ?? "jpeg",
10791079
// Each chunk produces video only — audio is muxed once at assemble
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import { shouldUseLayeredComposite } from "../hdrCompositor.js";
3+
import { createCapturePlan } from "./capturePlan.js";
4+
import { assertOutputScaleSupported } from "./outputScaleRoute.js";
5+
import { outputSupportsPageSideShaderCompositing } from "./renderFormat.js";
6+
import { resolveDeviceScaleFactor } from "./shared.js";
7+
8+
describe("assertOutputScaleSupported", () => {
9+
it("refuses a scaled output on the layered route, larger or smaller", () => {
10+
for (const scale of [2, 1280 / 1920, 854 / 1920])
11+
expect(() => assertOutputScaleSupported(scale, "hdr_layered")).toThrow(/hdr_layered/);
12+
});
13+
14+
it("lets every route run at the composition's size, and the screenshot routes scale", () => {
15+
for (const route of ["sdr_streaming", "sdr_disk", "sdr_segmented", "hdr_layered"] as const)
16+
expect(() => assertOutputScaleSupported(1, route)).not.toThrow();
17+
for (const route of ["sdr_streaming", "sdr_disk", "sdr_segmented"] as const)
18+
expect(() => assertOutputScaleSupported(854 / 1920, route)).not.toThrow();
19+
});
20+
21+
it("refuses 1080p from a 4K composition with shader transitions as mov, through the real route choice", () => {
22+
const pageSide = outputSupportsPageSideShaderCompositing("mov");
23+
const layered =
24+
!pageSide &&
25+
shouldUseLayeredComposite({
26+
hasHdrContent: false,
27+
hasShaderTransitions: true,
28+
isPngSequence: false,
29+
});
30+
const plan = createCapturePlan({
31+
workerCount: 1,
32+
forceScreenshot: false,
33+
useStreamingEncode: false,
34+
useLayeredComposite: layered,
35+
usePageSideCompositing: pageSide,
36+
hasHdrContent: false,
37+
needsAlpha: false,
38+
});
39+
const scale = resolveDeviceScaleFactor({
40+
compositionWidth: 3840,
41+
compositionHeight: 2160,
42+
outputResolution: "landscape",
43+
hdrRequested: false,
44+
alphaRequested: false,
45+
});
46+
expect(plan.kind).toBe("hdr_layered");
47+
expect(scale).toBe(0.5);
48+
expect(() => assertOutputScaleSupported(scale, plan.kind)).toThrow(/hdr_layered/);
49+
});
50+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* One owner for "does this capture route honour an output resolution other than the composition's".
3+
* The scale is Chrome's deviceScaleFactor; a route that sizes its own buffers and encoder from the
4+
* composition would encode at native size while the render reports the requested one.
5+
*/
6+
7+
import type { CapturePlan } from "./capturePlan.js";
8+
9+
/** Why a capture route cannot scale its output, or null when it can. */
10+
const UNSUPPORTED_REASON: Record<CapturePlan["kind"], string | null> = {
11+
sdr_streaming: null,
12+
sdr_disk: null,
13+
sdr_segmented: null,
14+
hdr_layered:
15+
"the layered compositor, used for HDR content and for shader transitions in webm and mov, " +
16+
"sizes its layers and its encoder from the composition",
17+
};
18+
19+
/** Throw a named error when an output resolution other than the composition's meets a route that ignores it. */
20+
export function assertOutputScaleSupported(
21+
deviceScaleFactor: number,
22+
route: CapturePlan["kind"],
23+
): void {
24+
if (deviceScaleFactor === 1) return;
25+
const reason = UNSUPPORTED_REASON[route];
26+
if (reason === null) return;
27+
throw new Error(
28+
`[Render] --resolution other than the composition's size is not supported on the "${route}" ` +
29+
`capture route: ${reason}. Render at the composition's size, or as mp4 with page-side compositing.`,
30+
);
31+
}

0 commit comments

Comments
 (0)