fix(cli): accept portrait aspects for --resolution alias flag

The aspect-agnostic resolution aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`) previously all normalized to a landscape preset, which rejected portrait 1080x1920 compositions with 'Output resolution incompatible'. Users had to specify the orientation-bearing alias (`1080p-portrait`) or render at native.

This threads two new fields (`outputResolutionAspectAgnostic` + `outputResolutionRaw`) through the render pipeline. At the CLI layer we detect whether the user's flag was an aspect-agnostic alias; at the compile stage we re-map the preset to the composition's orientation via the existing `suggestMatchingPreset` sibling-lookup (formerly private). Explicit orientation-bearing aliases and canonical presets stay strict.

Field signal: ts=1784176662 (darwin/arm64, CLI 0.7.59, `--resolution 1080p` on a 1080x1920 portrait comp).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

— Via
This commit is contained in:
Via
2026-07-16 06:23:14 +00:00
co-authored by Claude
parent 9ed255c0ef
commit 46e9ecf3f2
13 changed files with 460 additions and 15 deletions
+56 -2
View File
@@ -90,6 +90,7 @@ import {
} from "@hyperframes/engine";
import {
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
checkOutputResolutionCompatibility,
parseFps,
fpsToNumber,
@@ -481,6 +482,15 @@ export default defineCommand({
// ── Validate resolution ────────────────────────────────────────────────
let outputResolution: CanvasResolution | undefined;
// Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`) name
// a resolution *tier* without pinning an orientation. Historically they
// all normalize to a `landscape` preset, which rejects portrait/square
// compositions at `resolveDeviceScaleFactor` time. Track the raw-input
// shape so the compile stage can re-map the preset to the composition's
// orientation (see `outputResolutionAspectAgnostic` on RenderConfig).
// Explicit orientation-bearing aliases (`1080p-portrait`, `4k-square`, …)
// and canonical presets (`landscape`, `portrait`, …) stay strict.
let outputResolutionAspectAgnostic = false;
if (args.resolution !== undefined) {
outputResolution = normalizeResolutionFlag(args.resolution);
if (!outputResolution) {
@@ -491,6 +501,7 @@ export default defineCommand({
);
process.exit(1);
}
outputResolutionAspectAgnostic = isAspectAgnosticResolutionAlias(args.resolution);
// Reject the --resolution + --hdr combination at the CLI layer so the
// user sees the friendly errorBox before any work directories or
// ffmpeg processes spin up. The orchestrator also enforces this via
@@ -860,6 +871,7 @@ export default defineCommand({
{
alphaRequested: format === "webm" || format === "mov" || format === "png-sequence",
hdrRequested: args.hdr ?? false,
aspectAgnostic: outputResolutionAspectAgnostic,
},
);
} catch {
@@ -906,6 +918,8 @@ export default defineCommand({
browserPath,
entryFile,
outputResolution,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
@@ -973,6 +987,8 @@ export default defineCommand({
variables,
entryFile,
outputResolution,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
pageSideCompositing: args["page-side-compositing"] !== false,
experimentalFastCapture: args["experimental-fast-capture"] === true,
pageNavigationTimeoutMs,
@@ -1002,6 +1018,8 @@ export default defineCommand({
variables,
entryFile,
outputResolution,
outputResolutionAspectAgnostic,
outputResolutionRaw: args.resolution,
pageNavigationTimeoutMs,
protocolTimeout,
playerReadyTimeout,
@@ -1056,6 +1074,20 @@ interface RenderOptions {
exitAfterComplete?: boolean;
/** Output resolution preset; see `resolveDeviceScaleFactor` for constraints. */
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` came from an aspect-agnostic alias
* (`--resolution 1080p` / `hd` / `4k` / `uhd`). The compile stage adapts
* the preset to the composition's orientation instead of rejecting
* portrait/square comps as an aspect-ratio mismatch.
*/
outputResolutionAspectAgnostic?: boolean;
/**
* Raw `--resolution` string as typed by the user. Preserved so Docker mode
* can forward the pre-normalized flag to the in-container CLI, which
* re-runs the aspect-agnostic detection on its own side — otherwise we'd
* lose the "1080p was ambiguous" signal at the process boundary.
*/
outputResolutionRaw?: string;
pageSideCompositing?: boolean;
/** EXPERIMENTAL. drawElementImage frame capture (--experimental-fast-capture). */
experimentalFastCapture?: boolean;
@@ -1165,11 +1197,19 @@ async function readCompositionDimensions(
* Extracted (and exported) so the CLI wiring around `process.exit` stays a
* thin adapter and the branch logic is unit-testable. See render-reliability
* workstream P1-3.
*
* `aspectAgnostic` reflects whether `outputResolution` was normalized from an
* aspect-agnostic alias like `--resolution 1080p` / `hd` / `4k` / `uhd`.
* When true, an aspect-ratio mismatch is *not* an error at the CLI layer:
* the compile stage will re-map the preset to the composition's orientation
* (a portrait 1080×1920 composition with `--resolution 1080p` renders at
* 1080×1920, not 1920×1080). Alpha / HDR / downsampling / non-integer-scale
* checks still block, because those failures are not orientation-fixable.
*/
export async function checkRenderResolutionPreflight(
compositionHtml: string,
outputResolution: CanvasResolution | undefined,
modes: { alphaRequested: boolean; hdrRequested: boolean },
modes: { alphaRequested: boolean; hdrRequested: boolean; aspectAgnostic?: boolean },
): Promise<{ message: string; kind: OutputResolutionIssueKind } | undefined> {
if (!outputResolution) return undefined;
const dims = await readCompositionDimensions(compositionHtml);
@@ -1185,6 +1225,11 @@ export async function checkRenderResolutionPreflight(
});
// Narrow to the incompatible case; `message`/`kind` are always set there.
if (compat.ok || !compat.message || !compat.kind) return undefined;
// Aspect-agnostic aliases delegate orientation to the composition — a
// landscape-vs-portrait mismatch is expected and self-heals in the compile
// stage. Only *aspect-mismatch* is downgraded; other issue kinds still
// block (see the `aspectAgnostic` note above).
if (modes.aspectAgnostic && compat.kind === "aspect-mismatch") return undefined;
return { message: compat.message, kind: compat.kind };
}
@@ -1381,7 +1426,15 @@ async function renderDocker(
quiet: options.quiet,
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
// Forward the RAW `--resolution` flag (falling back to the canonical
// preset name when raw wasn't captured, e.g. programmatic callers).
// The in-container CLI re-runs `normalizeResolutionFlag` +
// `isAspectAgnosticResolutionAlias`, so aspect-agnostic aliases
// (`1080p`, `hd`, `4k`, `uhd`) retain their orientation-adaptive
// behavior inside Docker; passing the normalized `landscape` preset
// would silently lose that signal at the process boundary and
// reject portrait/square comps.
outputResolution: options.outputResolutionRaw ?? options.outputResolution,
pageSideCompositing: options.pageSideCompositing,
debug: options.debug,
bestEffort: options.bestEffort,
@@ -1534,6 +1587,7 @@ export async function renderLocal(
variables: options.variables,
entryFile: options.entryFile,
outputResolution: options.outputResolution,
outputResolutionAspectAgnostic: options.outputResolutionAspectAgnostic,
debug: options.debug,
strictness: options.bestEffort === false ? "strict" : "best-effort",
});