feat(producer): render at 720p and 480p from a larger composition

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-16 14:27:36 -07:00
parent 41486b9725
commit e72f6507c7
11 changed files with 138 additions and 78 deletions
+8 -15
View File
@@ -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,9 @@ 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("does NOT auto-clear when the flag was explicit (orientation-locked preset stays strict)", async () => {
@@ -1983,14 +1980,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 () => {
+6
View File
@@ -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",
]);
});
+6 -6
View File
@@ -907,18 +907,18 @@ async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boole
* screenshots carry a real alpha channel (Chrome resets the override on every
* navigation, so this must run after page load).
*
* drawElement is also skipped when supersampling (deviceScaleFactor > 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<void> {
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,
@@ -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", () => {
@@ -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,24 +68,35 @@ 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)
// 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 chosenIs4k = chosen.endsWith("-4k");
const sameTier = aspectMatches.find((preset) => preset.endsWith("-4k") === chosenIs4k);
const sameTier = aspectMatches.find((preset) => tierOf(preset) === tierOf(chosen));
return sameTier ?? aspectMatches[0];
}
/** `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 {
if (width > height) return "landscape";
if (width < height) return "portrait";
@@ -154,26 +164,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 {
+15
View File
@@ -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<string, CanvasResolution> = {
"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<string> = new Set([
"hd",
"4k",
"uhd",
"720p",
"480p",
]);
/**
@@ -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
@@ -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);
}
/**
@@ -336,10 +336,11 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
hdrRequested: job.config.hdrMode === "force-hdr",
alphaRequested: needsAlpha,
});
const outputWidth = width * deviceScaleFactor;
const outputHeight = height * deviceScaleFactor;
if (deviceScaleFactor > 1) {
log.info("Supersampling composition via deviceScaleFactor", {
// Rounded as Chrome sizes the screenshot: 854×480.375 → 854×480.
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,
@@ -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", () => {
@@ -1669,7 +1669,7 @@ export function scanElementTags(html: string): ElementTagScan {
// the count this gate reads. Converges: every iteration strictly shortens
// the string or changes nothing and exits.
let markup = html;
for (let previous = ""; markup !== previous; ) {
for (let previous = ""; markup !== previous;) {
previous = markup;
markup = markup.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
}
@@ -2122,7 +2122,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 +2559,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 +3643,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 +3736,7 @@ async function executeRenderPipeline(input: {
totalFrames,
minFrames: deParallelMinFrames,
layeredOrEffectRoute: hasHdrContent || compiled.hasShaderTransitions,
supersampling: deviceScaleFactor > 1,
supersampling: deviceScaleFactor !== 1,
probeDeGated:
probeSession !== null &&
probeSession.captureMode !== "drawelement" &&
@@ -4492,8 +4492,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 +4595,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,