From 09c0307c3fd0318a7ea77e6b41df4e5d30a1902b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 16 Oct 2026 20:58:15 -0700 Subject: [PATCH] feat(producer): render at 720p and 480p from a larger composition (#5724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 d33c4458: 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 e72f6507: 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. --- packages/cli/src/commands/render.test.ts | 32 +++++----- packages/core/src/index.test.ts | 6 ++ packages/engine/src/services/frameCapture.ts | 12 ++-- .../src/outputResolutionCompatibility.test.ts | 45 ++++++++++++-- .../src/outputResolutionCompatibility.ts | 58 +++++++++---------- packages/parsers/src/types.ts | 15 +++++ .../src/services/distributed/renderChunk.ts | 4 +- .../services/render/outputScaleRoute.test.ts | 50 ++++++++++++++++ .../src/services/render/outputScaleRoute.ts | 31 ++++++++++ .../producer/src/services/render/shared.ts | 15 +++-- .../render/stages/compileStage.test.ts | 13 +++++ .../services/render/stages/compileStage.ts | 8 +-- .../src/services/renderOrchestrator.test.ts | 17 +++++- .../src/services/renderOrchestrator.ts | 18 +++--- 14 files changed, 243 insertions(+), 81 deletions(-) create mode 100644 packages/producer/src/services/render/outputScaleRoute.test.ts create mode 100644 packages/producer/src/services/render/outputScaleRoute.ts diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 7a8a78066c..752189d78a 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -1871,10 +1871,10 @@ describe("checkRenderResolutionPreflight", () => { expect(result?.kind).toBe("hdr-incompatible"); }); - it("classifies a preset smaller than the composition as downsampling", async () => { - // 3840×2160 comp + landscape (1920×1080): same 16:9 aspect, target smaller. + it("passes a preset smaller than the composition", async () => { + // 3840×2160 comp + landscape (1920×1080): same 16:9 aspect, rendered at 0.5× DPR. const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", noModes); - expect(result?.kind).toBe("downsampling"); + expect(result).toBeUndefined(); }); it("classifies a non-integer upscale as non-integer-scale", async () => { @@ -1944,12 +1944,18 @@ describe("checkRenderResolutionPreflight", () => { expect(result?.kind).toBe("hdr-incompatible"); }); - it("still flags downsampling + aspect-agnostic (same-orientation, smaller preset)", async () => { - // 3840×2160 comp with `--resolution 1080p` → `landscape` (1920×1080). - // Same orientation, but tier smaller than comp — user asked for a - // downsample. That's a real incompatibility, not an orientation swap. + it("passes `--resolution 1080p` on a 4K composition (a smaller, same-orientation preset)", async () => { const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", agnostic); - expect(result?.kind).toBe("downsampling"); + expect(result).toBeUndefined(); + }); + + it("never adapts `--resolution 4k` to another tier: a 1922×1080 composition is refused, not rendered at 480p", async () => { + const result = await checkRenderResolutionPreflight( + comp(1922, 1080), + "landscape-4k", + agnostic, + ); + expect(result?.kind).toBeDefined(); }); it("does NOT auto-clear when the flag was explicit (orientation-locked preset stays strict)", async () => { @@ -1983,14 +1989,10 @@ describe("checkRenderResolutionPreflight", () => { expect(result?.message).toMatch(/preset whose orientation matches|omit --resolution/i); }); - it("blocks a portrait-4K comp + `1080p` downsample early (orientation-flip masks the tier gap)", async () => { - // 2160×3840 (portrait 4K) + `--resolution 1080p` — the compile stage - // remaps `landscape` → `portrait` (1080×1920), and *then* the preset - // is smaller than the composition. The un-remapped preflight let this - // slip through as an aspect-mismatch downgrade; the remap-then-check - // catches the real failure — downsampling — early. + it("passes a portrait-4K comp + `1080p` once remapped to portrait (a smaller preset)", async () => { + // 2160×3840 + `--resolution 1080p` remaps `landscape` → `portrait` (1080×1920), rendered at 0.5× DPR. const result = await checkRenderResolutionPreflight(comp(2160, 3840), "landscape", agnostic); - expect(result?.kind).toBe("downsampling"); + expect(result).toBeUndefined(); }); it("blocks a portrait 720p comp + `1080p` non-integer upscale early (orientation-flip masks the fractional DPR)", async () => { diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index 793951ba49..730c14d7bf 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -22,6 +22,12 @@ describe("@hyperframes/core public API exports", () => { "portrait-4k", "square", "square-4k", + "landscape-720p", + "portrait-720p", + "square-720p", + "landscape-480p", + "portrait-480p", + "square-480p", ]); }); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 8e612944e9..c71a963b5f 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -907,18 +907,18 @@ async function waitForCloseWithTimeout(promise: Promise): Promise 1): + * drawElement is also skipped when scaling (deviceScaleFactor other than 1): * `drawElementImage` reads the canvas at CSS pixels and has no equivalent of * `Page.captureScreenshot`'s clip+scale, so it would silently capture at 1x and * drop the requested supersample. Such renders fall through to the screenshot - * path (preMode already forces "screenshot" for DPR > 1). + * path (preMode already forces "screenshot" for DPR other than 1). */ async function initDrawElementOrTransparentBackground( session: CaptureSession, page: Page, logInitPhase: (phase: string) => void, ): Promise { - const supersampling = (session.options.deviceScaleFactor ?? 1) > 1; + const supersampling = (session.options.deviceScaleFactor ?? 1) !== 1; // forceScreenshot is an explicit routing decision made upstream (render-mode // compat hints like raw requestAnimationFrame, alpha formats, low-memory) — // drawElement must not override it. Concretely: an rAF-compat comp on @@ -948,7 +948,7 @@ async function initDrawElementOrTransparentBackground( session.deFallbackTrigger = "supersampling"; console.log( "[engine] --experimental-fast-capture disabled for this render: drawElementImage " + - "ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.", + "ignores deviceScaleFactor, so scaled (DPR other than 1) output uses screenshot capture.", ); } if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) { @@ -1286,8 +1286,8 @@ export async function createCaptureSession( // (the captured surface is sized by the OS window in CSS pixels regardless // of `Emulation.setDeviceMetricsOverride`'s DPR). When supersampling we // need explicit clip+scale on `Page.captureScreenshot`, so fall back to - // the screenshot path for any DPR > 1. - const supersampling = (options.deviceScaleFactor ?? 1) > 1; + // the screenshot path for any DPR other than 1. + const supersampling = (options.deviceScaleFactor ?? 1) !== 1; const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode; const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, { chromePath: headlessShell ?? undefined, diff --git a/packages/parsers/src/outputResolutionCompatibility.test.ts b/packages/parsers/src/outputResolutionCompatibility.test.ts index 9e154e35f1..7c34794281 100644 --- a/packages/parsers/src/outputResolutionCompatibility.test.ts +++ b/packages/parsers/src/outputResolutionCompatibility.test.ts @@ -116,15 +116,52 @@ describe("checkOutputResolutionCompatibility", () => { }); describe("scale constraints", () => { - it("flags downsampling (preset smaller than composition)", () => { - // 3840×2160 composition, landscape (1920×1080) preset → 0.5× DPR. + it("allows a preset smaller than the composition (0.5× DPR)", () => { const result = checkOutputResolutionCompatibility({ compositionWidth: 3840, compositionHeight: 2160, outputResolution: "landscape", }); - expect(result.ok).toBe(false); - expect(result.kind).toBe("downsampling"); + expect(result.ok).toBe(true); + }); + + it.each([ + [1920, 1080, "landscape-720p"], + [1920, 1080, "landscape-480p"], + [1080, 1920, "portrait-720p"], + [1080, 1920, "portrait-480p"], + [1080, 1080, "square-720p"], + [1080, 1080, "square-480p"], + ] as const)( + "allows %i×%i down to %s", + (compositionWidth, compositionHeight, outputResolution) => { + expect( + checkOutputResolutionCompatibility({ + compositionWidth, + compositionHeight, + outputResolution, + }), + ).toEqual({ ok: true }); + }, + ); + + it("keeps the 480p tier when suggesting a swap for the wrong orientation", () => { + const result = checkOutputResolutionCompatibility({ + compositionWidth: 1920, + compositionHeight: 1080, + outputResolution: "portrait-480p", + }); + expect(result.kind).toBe("aspect-mismatch"); + expect(result.suggestedResolution).toBe("landscape-480p"); + }); + + it("still refuses a near-16:9 composition a pixel too wide for an exact preset", () => { + const result = checkOutputResolutionCompatibility({ + compositionWidth: 1921, + compositionHeight: 1080, + outputResolution: "landscape", + }); + expect(result.kind).toBe("aspect-mismatch"); }); it("flags a non-integer scale factor for a same-aspect custom composition", () => { diff --git a/packages/parsers/src/outputResolutionCompatibility.ts b/packages/parsers/src/outputResolutionCompatibility.ts index 34fd3932ba..bd942712fd 100644 --- a/packages/parsers/src/outputResolutionCompatibility.ts +++ b/packages/parsers/src/outputResolutionCompatibility.ts @@ -28,7 +28,6 @@ import { CANVAS_DIMENSIONS, VALID_CANVAS_RESOLUTIONS, type CanvasResolution } fr export type OutputResolutionIssueKind = | "hdr-incompatible" | "aspect-mismatch" - | "downsampling" | "non-integer-scale"; export interface OutputResolutionCompatibility { @@ -69,22 +68,31 @@ export function suggestMatchingPreset( compositionHeight: number, chosen: CanvasResolution, ): CanvasResolution | undefined { - const aspectMatches: CanvasResolution[] = VALID_CANVAS_RESOLUTIONS.filter((preset) => { - const { width, height } = CANVAS_DIMENSIONS[preset]; - // Integer-safe aspect compare (cross-multiplication), matching the - // producer's mismatch check exactly. - return width * compositionHeight === height * compositionWidth; - }); + const aspectMatches: CanvasResolution[] = VALID_CANVAS_RESOLUTIONS.filter((preset) => + fitsAspect(CANVAS_DIMENSIONS[preset], compositionWidth, compositionHeight), + ); if (aspectMatches.length === 0) return undefined; - // Prefer the aspect-matching preset in the same resolution tier as the chosen - // one so we don't silently downgrade 4K → HD (or vice versa). Tier is keyed - // off the `-4k` suffix rather than long-side pixels: a 4K *square* (2160×2160) - // has a shorter long side than 4K landscape (3840), so a pixel-based compare - // would fail to recognise `square-4k` as the 4K peer and downgrade to `square`. - const chosenIs4k = chosen.endsWith("-4k"); - const sameTier = aspectMatches.find((preset) => preset.endsWith("-4k") === chosenIs4k); - return sameTier ?? aspectMatches[0]; + // Only the aspect-matching preset in the chosen tier, never another tier: callers apply this + // automatically, and a 4K request must not become HD or 480p. Tier is keyed off the suffix + // (`-4k`, `-720p`, …) rather than long-side pixels: a 4K *square* (2160×2160) has a shorter long + // side than 4K landscape (3840), so a pixel-based compare would miss `square-4k` as the 4K peer. + return aspectMatches.find((preset) => tierOf(preset) === tierOf(chosen)); +} + +/** `landscape-4k` → `-4k`, `portrait` → `` (1080p). */ +const tierOf = (preset: CanvasResolution) => + preset.includes("-") ? preset.slice(preset.indexOf("-")) : ""; + +/** Integer-safe aspect compare. A smaller preset may be under a pixel off on its long side (854×480 at 16:9). */ +function fitsAspect( + target: { width: number; height: number }, + compositionWidth: number, + compositionHeight: number, +): boolean { + const drift = Math.abs(target.width * compositionHeight - target.height * compositionWidth); + if (drift === 0) return true; + return target.width < compositionWidth && drift < Math.min(compositionWidth, compositionHeight); } function describeOrientation(width: number, height: number): string { @@ -154,26 +162,14 @@ export function checkOutputResolutionCompatibility(input: { } const target = CANVAS_DIMENSIONS[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 * compositionHeight !== target.height * compositionWidth) { + if (!fitsAspect(target, compositionWidth, compositionHeight)) { return buildAspectMismatch(compositionWidth, compositionHeight, outputResolution, target); } - // Aspect ratios match → widthRatio === heightRatio. Compute once. + // A smaller preset renders at a device scale factor under 1: Chrome lays the + // page out at composition size and rasterizes it straight at the preset's. const widthRatio = target.width / compositionWidth; - if (widthRatio < 1) { - return { - ok: false, - kind: "downsampling", - message: - `outputResolution ${outputResolution} (${target.width}×${target.height}) ` + - `is smaller than the composition (${compositionWidth}×${compositionHeight}). ` + - `Downsampling via --resolution is not supported.`, - }; - } + if (widthRatio < 1) return OK; if (!Number.isInteger(widthRatio)) { return { diff --git a/packages/parsers/src/types.ts b/packages/parsers/src/types.ts index 9f964a7ff6..493cda018e 100644 --- a/packages/parsers/src/types.ts +++ b/packages/parsers/src/types.ts @@ -23,6 +23,13 @@ export const CANVAS_DIMENSIONS = { "portrait-4k": { width: 2160, height: 3840 }, square: { width: 1080, height: 1080 }, "square-4k": { width: 2160, height: 2160 }, + "landscape-720p": { width: 1280, height: 720 }, + "portrait-720p": { width: 720, height: 1280 }, + "square-720p": { width: 720, height: 720 }, + // 480 tall at 16:9 is 853.33 wide: 854 keeps both sides even for 4:2:0 encoders. + "landscape-480p": { width: 854, height: 480 }, + "portrait-480p": { width: 480, height: 854 }, + "square-480p": { width: 480, height: 480 }, } as const; // Single source of truth: derive the type from the table so adding a preset @@ -49,6 +56,12 @@ const RESOLUTION_ALIASES: Record = { "1080p-square": "square", "square-1080p": "square", "4k-square": "square-4k", + "720p": "landscape-720p", + "720p-portrait": "portrait-720p", + "720p-square": "square-720p", + "480p": "landscape-480p", + "480p-portrait": "portrait-480p", + "480p-square": "square-480p", }; /** @@ -70,6 +83,8 @@ const ASPECT_AGNOSTIC_RESOLUTION_ALIASES: ReadonlySet = new Set([ "hd", "4k", "uhd", + "720p", + "480p", ]); /** diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index 44c5cbdc80..0a85a3006c 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -1072,8 +1072,8 @@ export async function renderChunk( outputPath: outputChunkPath, framesDir, videoOnlyPath, - width: plan.dimensions.width * encoder.deviceScaleFactor, - height: plan.dimensions.height * encoder.deviceScaleFactor, + width: Math.round(plan.dimensions.width * encoder.deviceScaleFactor), + height: Math.round(plan.dimensions.height * encoder.deviceScaleFactor), needsAlpha: plan.dimensions.format !== "mp4", captureImageFormat: captureOptions.format ?? "jpeg", // Each chunk produces video only — audio is muxed once at assemble diff --git a/packages/producer/src/services/render/outputScaleRoute.test.ts b/packages/producer/src/services/render/outputScaleRoute.test.ts new file mode 100644 index 0000000000..306b953210 --- /dev/null +++ b/packages/producer/src/services/render/outputScaleRoute.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { shouldUseLayeredComposite } from "../hdrCompositor.js"; +import { createCapturePlan } from "./capturePlan.js"; +import { assertOutputScaleSupported } from "./outputScaleRoute.js"; +import { outputSupportsPageSideShaderCompositing } from "./renderFormat.js"; +import { resolveDeviceScaleFactor } from "./shared.js"; + +describe("assertOutputScaleSupported", () => { + it("refuses a scaled output on the layered route, larger or smaller", () => { + for (const scale of [2, 1280 / 1920, 854 / 1920]) + expect(() => assertOutputScaleSupported(scale, "hdr_layered")).toThrow(/hdr_layered/); + }); + + it("lets every route run at the composition's size, and the screenshot routes scale", () => { + for (const route of ["sdr_streaming", "sdr_disk", "sdr_segmented", "hdr_layered"] as const) + expect(() => assertOutputScaleSupported(1, route)).not.toThrow(); + for (const route of ["sdr_streaming", "sdr_disk", "sdr_segmented"] as const) + expect(() => assertOutputScaleSupported(854 / 1920, route)).not.toThrow(); + }); + + it("refuses 1080p from a 4K composition with shader transitions as mov, through the real route choice", () => { + const pageSide = outputSupportsPageSideShaderCompositing("mov"); + const layered = + !pageSide && + shouldUseLayeredComposite({ + hasHdrContent: false, + hasShaderTransitions: true, + isPngSequence: false, + }); + const plan = createCapturePlan({ + workerCount: 1, + forceScreenshot: false, + useStreamingEncode: false, + useLayeredComposite: layered, + usePageSideCompositing: pageSide, + hasHdrContent: false, + needsAlpha: false, + }); + const scale = resolveDeviceScaleFactor({ + compositionWidth: 3840, + compositionHeight: 2160, + outputResolution: "landscape", + hdrRequested: false, + alphaRequested: false, + }); + expect(plan.kind).toBe("hdr_layered"); + expect(scale).toBe(0.5); + expect(() => assertOutputScaleSupported(scale, plan.kind)).toThrow(/hdr_layered/); + }); +}); diff --git a/packages/producer/src/services/render/outputScaleRoute.ts b/packages/producer/src/services/render/outputScaleRoute.ts new file mode 100644 index 0000000000..6b3256ccf9 --- /dev/null +++ b/packages/producer/src/services/render/outputScaleRoute.ts @@ -0,0 +1,31 @@ +/** + * One owner for "does this capture route honour an output resolution other than the composition's". + * The scale is Chrome's deviceScaleFactor; a route that sizes its own buffers and encoder from the + * composition would encode at native size while the render reports the requested one. + */ + +import type { CapturePlan } from "./capturePlan.js"; + +/** Why a capture route cannot scale its output, or null when it can. */ +const UNSUPPORTED_REASON: Record = { + sdr_streaming: null, + sdr_disk: null, + sdr_segmented: null, + hdr_layered: + "the layered compositor, used for HDR content and for shader transitions in webm and mov, " + + "sizes its layers and its encoder from the composition", +}; + +/** Throw a named error when an output resolution other than the composition's meets a route that ignores it. */ +export function assertOutputScaleSupported( + deviceScaleFactor: number, + route: CapturePlan["kind"], +): void { + if (deviceScaleFactor === 1) return; + const reason = UNSUPPORTED_REASON[route]; + if (reason === null) return; + throw new Error( + `[Render] --resolution other than the composition's size is not supported on the "${route}" ` + + `capture route: ${reason}. Render at the composition's size, or as mp4 with page-side compositing.`, + ); +} diff --git a/packages/producer/src/services/render/shared.ts b/packages/producer/src/services/render/shared.ts index 9e07ff286e..a98e841260 100644 --- a/packages/producer/src/services/render/shared.ts +++ b/packages/producer/src/services/render/shared.ts @@ -105,18 +105,16 @@ export function projectBrowserEndToCompositionTimeline( * `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 + * Upsampling must be by a whole number — fractional DPRs above 1 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. + * 4K render. A smaller preset (720p, 480p) takes a DPR under 1, so Chrome + * rasterizes straight at the smaller size. * * Throws on: * - HDR + outputResolution (HDR compositor processes raw pixel buffers * at composition dimensions and would need parallel scaling). * - Aspect-ratio mismatch (e.g. landscape composition → portrait-4k). - * - Non-integer scale ratio. - * - Downsampling (output dimensions smaller than composition). + * - Non-integer upsampling ratio. */ export function resolveDeviceScaleFactor(input: { compositionWidth: number; @@ -140,8 +138,9 @@ export function resolveDeviceScaleFactor(input: { if (!compat.ok) throw new Error(compat.message); const target = CANVAS_DIMENSIONS[input.outputResolution]; - // Aspect ratios match → widthRatio === heightRatio. Compute once. - return target.width / input.compositionWidth; + // The two ratios match, except a smaller preset's long side rounded to an even pixel (854×480): the long side's, so + // Chrome draws all 854 and the short side's 480.375 rounds to 480. + return Math.max(target.width / input.compositionWidth, target.height / input.compositionHeight); } /** diff --git a/packages/producer/src/services/render/stages/compileStage.test.ts b/packages/producer/src/services/render/stages/compileStage.test.ts index 2132a27405..245124eac4 100644 --- a/packages/producer/src/services/render/stages/compileStage.test.ts +++ b/packages/producer/src/services/render/stages/compileStage.test.ts @@ -418,6 +418,19 @@ describe("runCompileStage — aspect-agnostic --resolution re-map", () => { expect(result.outputHeight).toBe(3840); }); + it("custom 1922×1080 composition + aspect-agnostic `landscape-4k` → refused, never adapted to 480p", async () => { + // Only 480p's tolerance fits 1922×1080; the adaptation stays in the asked tier, so 4K is + // checked as asked and refused instead of rendering 854×480. + await expect( + runResolutionCase({ + compWidth: 1922, + compHeight: 1080, + outputResolution: "landscape-4k", + aspectAgnostic: true, + }), + ).rejects.toThrow(); + }); + it("square composition + aspect-agnostic `landscape-4k` preset → re-maps to square-4k, preserves 4K tier", async () => { const result = await runResolutionCase({ compWidth: 1080, diff --git a/packages/producer/src/services/render/stages/compileStage.ts b/packages/producer/src/services/render/stages/compileStage.ts index 1ab3bd55ae..51bf5b7f20 100644 --- a/packages/producer/src/services/render/stages/compileStage.ts +++ b/packages/producer/src/services/render/stages/compileStage.ts @@ -336,10 +336,10 @@ export async function runCompileStage(input: CompileStageInput): Promise 1) { - log.info("Supersampling composition via deviceScaleFactor", { + const outputWidth = Math.round(width * deviceScaleFactor); + const outputHeight = Math.round(height * deviceScaleFactor); + if (deviceScaleFactor !== 1) { + log.info("Scaling composition via deviceScaleFactor", { compositionWidth: width, compositionHeight: height, outputResolution: effectiveResolution, diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 9cbbbaf7a4..57be38faef 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -1761,15 +1761,26 @@ describe("resolveDeviceScaleFactor", () => { ); }); - it("rejects downsampling (4K composition → 1080p output)", () => { - expect(() => + it("returns 0.5 for a 4K composition → 1080p output", () => { + expect( resolveDeviceScaleFactor({ ...defaults, compositionWidth: 3840, compositionHeight: 2160, outputResolution: "landscape", }), - ).toThrow(/Downsampling/); + ).toBe(0.5); + }); + + it("scales 1080p down for 720p and 480p, so 480p frames are 854×480", () => { + const scale = (outputResolution: "landscape-720p" | "landscape-480p") => + resolveDeviceScaleFactor({ ...defaults, outputResolution }); + expect(scale("landscape-720p")).toBeCloseTo(2 / 3); + expect(scale("landscape-480p")).toBe(854 / 1920); + expect([ + Math.round(1920 * scale("landscape-480p")), + Math.round(1080 * scale("landscape-480p")), + ]).toEqual([854, 480]); }); it("rejects non-integer scale factors", () => { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 8e0b95be09..6e95a1f072 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -217,6 +217,7 @@ import { resolveCaptureJpegQuality, } from "./render/captureImageFormat.js"; import { assertMotionBlurSupported } from "./render/motionBlurRoute.js"; +import { assertOutputScaleSupported } from "./render/outputScaleRoute.js"; export function sampleDirectoryBytes(dir: string): number { let total = 0; @@ -2122,7 +2123,7 @@ export function shouldPreferSingleWorkerDrawElement(args: { * telemetry and keep the probe session alive through the heaviest stage. */ layeredOrEffectRoute: boolean; - /** deviceScaleFactor > 1 — the engine's supersampling gate blocks DE. */ + /** deviceScaleFactor other than 1 — the engine's supersampling gate blocks DE. */ supersampling: boolean; /** * The probe session already ran the engine's init-time DE gates and DE did @@ -2559,7 +2560,7 @@ export function resolveParallelCaptureMode(args: { forceScreenshot: boolean; deviceScaleFactor?: number; }): "screenshot" | "beginframe" { - const supersampling = (args.deviceScaleFactor ?? 1) > 1; + const supersampling = (args.deviceScaleFactor ?? 1) !== 1; return args.headlessShell && args.platform === "linux" && !args.forceScreenshot && !supersampling ? "beginframe" : "screenshot"; @@ -3643,7 +3644,7 @@ async function executeRenderPipeline(input: { minFrames: deSingleMinFrames, singleWorkerStreamingOk: shouldUseStreamingEncode(cfg, outputFormat, 1, job.duration), layeredOrEffectRoute: hasHdrContent || compiled.hasShaderTransitions, - supersampling: deviceScaleFactor > 1, + supersampling: deviceScaleFactor !== 1, probeDeGated: probeSession !== null && probeSession.captureMode !== "drawelement" && @@ -3736,7 +3737,7 @@ async function executeRenderPipeline(input: { totalFrames, minFrames: deParallelMinFrames, layeredOrEffectRoute: hasHdrContent || compiled.hasShaderTransitions, - supersampling: deviceScaleFactor > 1, + supersampling: deviceScaleFactor !== 1, probeDeGated: probeSession !== null && probeSession.captureMode !== "drawelement" && @@ -4276,6 +4277,7 @@ async function executeRenderPipeline(input: { // Every route the render can end up on passes through here, including the ones a // replan lands on, so this is where motion blur's one supported-route answer belongs. assertMotionBlurSupported(job.config.motionBlur, capturePlan.kind); + assertOutputScaleSupported(deviceScaleFactor, capturePlan.kind); workerCount = capturePlan.workerCount; captureForceScreenshot = capturePlan.forceScreenshot; useStreamingEncode = capturePlan.kind === "sdr_streaming"; @@ -4492,8 +4494,8 @@ async function executeRenderPipeline(input: { outputFormat, streamingEncoderOptions: { fps: job.config.fps, - width, - height, + width: captureCompositionWidth ?? width, + height: captureCompositionHeight ?? height, codec: preset.codec, preset: preset.preset, quality: effectiveQuality, @@ -4595,8 +4597,8 @@ async function executeRenderPipeline(input: { outputFormat, streamingEncoderOptions: { fps: job.config.fps, - width, - height, + width: captureCompositionWidth ?? width, + height: captureCompositionHeight ?? height, codec: preset.codec, preset: preset.preset, quality: effectiveQuality,