fix(render): pre-flight aspect-ratio / alpha preset mismatch with actionable guidance (#1843)

Users pick an --resolution preset whose orientation/aspect ratio (or alpha/HDR mode) conflicts with the composition; the render fails deep in the compiler with a cryptic message. ~8K err / ~1K users.

- New shared pure helper checkOutputResolutionCompatibility in @hyperframes/parsers — single source of truth for aspect/alpha/HDR/downsample/non-integer-scale constraints; suggests the matching-orientation, tier-preserving preset.
- CLI render pre-flight aborts early (before browser/ffmpeg) with an actionable, fix-suggesting message; resolveDeviceScaleFactor delegates to the same helper for identical defense-in-depth messages.
- Suggest (not auto-select); defers when dims can't be determined rather than guessing.
- suggestMatchingPreset keys tier off the -4k suffix so square-family swaps (square + landscape-4k -> square-4k) aren't downgraded to HD.
- render.js DOM polyfill made a lazy import; render.test cold-import beforeAll hooks given a 30s timeout to absorb CI contention.

Render-reliability workstream P1-3. Success measured on PostHog dashboard 1783183.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-07-01 19:27:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c0c3abf0f1
commit 6be46813a2
10 changed files with 554 additions and 53 deletions
+19 -45
View File
@@ -12,7 +12,11 @@
import { copyFileSync, cpSync, existsSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import { CANVAS_DIMENSIONS, type CanvasResolution } from "@hyperframes/core";
import {
CANVAS_DIMENSIONS,
checkOutputResolutionCompatibility,
type CanvasResolution,
} from "@hyperframes/core";
import type {
AudioElement,
ExtractedFrames,
@@ -94,52 +98,22 @@ export function resolveDeviceScaleFactor(input: {
alphaRequested: boolean;
}): number {
if (!input.outputResolution) return 1;
if (input.hdrRequested) {
throw new Error(
"outputResolution cannot be combined with hdrMode='force-hdr'. " +
"HDR rendering composites at composition dimensions and does not yet " +
"support supersampling. Pick one or render in two passes.",
);
}
if (input.alphaRequested) {
throw new Error(
"outputResolution cannot be combined with alpha output (--format webm|mov|png-sequence). " +
"The alpha screenshot path does not yet apply deviceScaleFactor and would silently " +
"produce composition-resolution frames. Render alpha at composition resolution and " +
"upscale separately, or use --format mp4.",
);
}
// Single source of truth for the aspect/alpha/HDR/scale constraints, shared
// with the CLI render pre-flight so both raise the identical, actionable
// message. This is the deep defense-in-depth throw; the pre-flight aborts
// long before this runs on the common (aspect/alpha) mistakes.
const compat = checkOutputResolutionCompatibility({
compositionWidth: input.compositionWidth,
compositionHeight: input.compositionHeight,
outputResolution: input.outputResolution,
alphaRequested: input.alphaRequested,
hdrRequested: input.hdrRequested,
});
if (!compat.ok) throw new Error(compat.message);
const target = CANVAS_DIMENSIONS[input.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 * input.compositionHeight !== target.height * input.compositionWidth) {
throw new Error(
`outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` +
`does not match the aspect ratio of the composition ` +
`(${input.compositionWidth}×${input.compositionHeight}). ` +
`Pick a preset whose orientation matches.`,
);
}
// Aspect ratios match → widthRatio === heightRatio. Compute once.
const widthRatio = target.width / input.compositionWidth;
if (widthRatio < 1) {
throw new Error(
`outputResolution ${input.outputResolution} (${target.width}×${target.height}) ` +
`is smaller than the composition (${input.compositionWidth}×${input.compositionHeight}). ` +
`Downsampling via --resolution is not supported.`,
);
}
if (!Number.isInteger(widthRatio)) {
throw new Error(
`outputResolution ${input.outputResolution} requires a non-integer ` +
`device scale factor (${widthRatio}×) to upsample from ` +
`${input.compositionWidth}×${input.compositionHeight}. ` +
`Pick a preset that's an integer multiple, or rescale the composition.`,
);
}
return widthRatio;
return target.width / input.compositionWidth;
}
/**
@@ -1313,6 +1313,14 @@ describe("resolveDeviceScaleFactor", () => {
).toThrow(/aspect ratio/);
});
it("suggests the matching-orientation preset in the aspect-mismatch message", () => {
// Landscape composition + portrait preset → the message should point at
// the landscape swap so the user isn't left to guess (workstream P1-3).
expect(() => resolveDeviceScaleFactor({ ...defaults, outputResolution: "portrait" })).toThrow(
/--resolution landscape/,
);
});
it("rejects downsampling (4K composition → 1080p output)", () => {
expect(() =>
resolveDeviceScaleFactor({