fix(producer): refuse a scaled render on the layered route and keep the aspect remap in its tier

The layered compositor sizes its buffers and encoder from the composition, so a scaled output there now stops
with a named error instead of encoding at native size. The automatic aspect remap returns only a preset in the
asked tier, so a 4k request is refused rather than rendered at 480p.
This commit is contained in:
Miguel Angel Simon Sierra
2026-10-16 16:35:38 -07:00
parent e72f6507c7
commit d33c445834
6 changed files with 110 additions and 7 deletions
+9
View File
@@ -1949,6 +1949,15 @@ describe("checkRenderResolutionPreflight", () => {
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 () => {
// The negative case: `--resolution landscape` on a portrait comp — the
// user explicitly asked for landscape orientation, and the mismatch is
@@ -73,13 +73,11 @@ export function suggestMatchingPreset(
);
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 change 4K → HD (or vice versa). 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 fail to recognise `square-4k` as the 4K peer and downgrade to `square`.
const sameTier = aspectMatches.find((preset) => tierOf(preset) === tierOf(chosen));
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). */
@@ -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/);
});
});
@@ -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<CapturePlan["kind"], string | null> = {
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.`,
);
}
@@ -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,
@@ -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;
@@ -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";