mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user