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
+75
View File
@@ -1170,6 +1170,81 @@ describe("checkRenderResolutionPreflight", () => {
await checkRenderResolutionPreflight("<html><body></body></html>", "landscape", noModes),
).toBeUndefined();
});
// Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`) name a
// resolution tier without pinning an orientation. When the flag is
// aspect-agnostic the pre-flight must NOT block on an aspect-ratio mismatch —
// the compile stage adapts the preset to the composition's orientation
// downstream (see `outputResolutionAspectAgnostic` on RenderConfig).
// Field signal ts=1784176662 (darwin/arm64, CLI 0.7.59):
// "--resolution 1080p rejects a 1080x1920 portrait comp"
describe("aspect-agnostic (--resolution 1080p / hd / 4k / uhd)", () => {
const agnostic = { ...noModes, aspectAgnostic: true } as const;
it("clears a landscape preset on a portrait composition (the field-signal scenario)", async () => {
// The bug: --resolution 1080p normalized to `landscape` (1920×1080),
// then errored on a 1080×1920 portrait comp with "Output resolution
// incompatible." With aspectAgnostic=true the pre-flight steps aside
// and the compile stage re-maps landscape → portrait.
expect(
await checkRenderResolutionPreflight(portraitHtml, "landscape", agnostic),
).toBeUndefined();
});
it("clears a landscape-4k preset on a portrait composition (4K tier)", async () => {
// `--resolution 4k` → normalized `landscape-4k`. Portrait comp is fine
// when aspect-agnostic.
expect(
await checkRenderResolutionPreflight(portraitHtml, "landscape-4k", agnostic),
).toBeUndefined();
});
it("clears a landscape preset on a square composition", async () => {
// aspect > 1 → landscape, aspect = 1 → square. Both self-heal.
expect(
await checkRenderResolutionPreflight(comp(1080, 1080), "landscape", agnostic),
).toBeUndefined();
});
it("still flags alpha + aspect-agnostic (orientation isn't the issue)", async () => {
// alpha-incompatible is orthogonal to aspect: the alpha capture path
// can't apply deviceScaleFactor regardless of orientation. The
// aspect-agnostic downgrade must NOT swallow this.
const result = await checkRenderResolutionPreflight(portraitHtml, "landscape", {
aspectAgnostic: true,
alphaRequested: true,
hdrRequested: false,
});
expect(result?.kind).toBe("alpha-incompatible");
});
it("still flags HDR + aspect-agnostic", async () => {
const result = await checkRenderResolutionPreflight(landscapeHtml, "landscape", {
aspectAgnostic: true,
alphaRequested: false,
hdrRequested: true,
});
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.
const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", agnostic);
expect(result?.kind).toBe("downsampling");
});
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
// a genuine mistake. Pre-flight must still block with the actionable
// "did you mean --resolution portrait?" suggestion.
const result = await checkRenderResolutionPreflight(portraitHtml, "landscape", noModes);
expect(result?.kind).toBe("aspect-mismatch");
expect(result?.message).toContain("--resolution portrait");
});
});
});
describe("render fps arg definition", () => {
+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",
});
+2
View File
@@ -179,7 +179,9 @@ export {
CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
checkOutputResolutionCompatibility,
suggestMatchingPreset,
COMPOSITION_VARIABLE_TYPES,
TIMELINE_COLORS,
DEFAULT_DURATIONS,
+24
View File
@@ -39,6 +39,30 @@ describe("@hyperframes/core public API exports", () => {
expect(core.normalizeResolutionFlag(undefined)).toBeUndefined();
});
it("exports isAspectAgnosticResolutionAlias for tier-only aliases", () => {
// Tier-only aliases → true (orientation follows the composition).
expect(core.isAspectAgnosticResolutionAlias("1080p")).toBe(true);
expect(core.isAspectAgnosticResolutionAlias("hd")).toBe(true);
expect(core.isAspectAgnosticResolutionAlias("4k")).toBe(true);
expect(core.isAspectAgnosticResolutionAlias("uhd")).toBe(true);
// Case-insensitive.
expect(core.isAspectAgnosticResolutionAlias("1080P")).toBe(true);
expect(core.isAspectAgnosticResolutionAlias("UHD")).toBe(true);
// Orientation-suffixed aliases → false (user picked an orientation).
expect(core.isAspectAgnosticResolutionAlias("1080p-portrait")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("portrait-1080p")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("4k-square")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("1080p-square")).toBe(false);
// Canonical presets → false.
expect(core.isAspectAgnosticResolutionAlias("landscape")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("portrait")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("landscape-4k")).toBe(false);
// Unknown / empty / undefined → false.
expect(core.isAspectAgnosticResolutionAlias("8k")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias("")).toBe(false);
expect(core.isAspectAgnosticResolutionAlias(undefined)).toBe(false);
});
it("exports TIMELINE_COLORS", () => {
expect(core.TIMELINE_COLORS).toBeDefined();
expect(core.TIMELINE_COLORS.video).toBeDefined();
+2
View File
@@ -55,7 +55,9 @@ export {
CANVAS_DIMENSIONS,
VALID_CANVAS_RESOLUTIONS,
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
checkOutputResolutionCompatibility,
suggestMatchingPreset,
parseFps,
parseFpsWithDefault,
toFps,
@@ -59,8 +59,13 @@ const OK: OutputResolutionCompatibility = { ok: true };
* Returns `undefined` when no preset matches the composition's aspect ratio
* (e.g. a custom, non-preset composition aspect ratio) in that case there
* is no unambiguous swap to suggest.
*
* Exported so that consumers with permission to auto-apply the swap (see the
* `--resolution 1080p` aspect-agnostic path in the CLI/producer) can share the
* same sibling-lookup logic as this module's user-facing "did you mean?" hint,
* without duplicating the tier-preserving fallback rules.
*/
function suggestMatchingPreset(
export function suggestMatchingPreset(
compositionWidth: number,
compositionHeight: number,
chosen: CanvasResolution,
+39
View File
@@ -51,6 +51,26 @@ const RESOLUTION_ALIASES: Record<string, CanvasResolution> = {
"4k-square": "square-4k",
};
/**
* Aliases that name a resolution *tier* (1080p / 4K) without also nailing an
* orientation. Historically they all normalize to the `landscape` preset
* (`normalizeResolutionFlag("1080p") === "landscape"`), which then rejects
* portrait / square compositions with a cryptic "aspect ratio does not match"
* error deep inside the render pipeline. Consumers that know the composition's
* dimensions can consult this set to decide whether to auto-adapt the preset's
* orientation instead see `resolveResolutionForComposition`.
*
* Orientation-suffixed aliases (`1080p-portrait`, `4k-square`, ) are absent
* on purpose: the user *did* pick an orientation, and honoring it is important
* for the "you asked for landscape but composed portrait" error to still fire.
*/
const ASPECT_AGNOSTIC_RESOLUTION_ALIASES: ReadonlySet<string> = new Set([
"1080p",
"hd",
"4k",
"uhd",
]);
/**
* Map a user-facing resolution string (canonical name or alias) to a
* `CanvasResolution`. Returns undefined for unknown values so callers
@@ -65,6 +85,25 @@ export function normalizeResolutionFlag(input: string | undefined): CanvasResolu
return RESOLUTION_ALIASES[lowered];
}
/**
* True when `input` names a resolution *tier* without nailing an orientation
* (`1080p`, `hd`, `4k`, `uhd`). Case-insensitive.
*
* The `--resolution` CLI flag treats these as "target this size; keep the
* composition's orientation" a portrait 1080×1920 comp with `--resolution
* 1080p` should land at 1080×1920, not blow up on aspect mismatch. Explicit
* canonical presets (`landscape`, `portrait`, ) and orientation-suffixed
* aliases (`1080p-portrait`) stay strict the user picked an orientation.
*
* Consumers pair this signal with the composition's dimensions (in a
* follow-up pass after HTML parse) to pick the right preset via
* `resolveResolutionForComposition`.
*/
export function isAspectAgnosticResolutionAlias(input: string | undefined): boolean {
if (!input) return false;
return ASPECT_AGNOSTIC_RESOLUTION_ALIASES.has(input.toLowerCase());
}
export interface TimelineElementBase {
id: string;
type: TimelineElementType;
+32 -8
View File
@@ -43,7 +43,12 @@ import { isVideoFrameFormat } from "@hyperframes/engine";
import { resolveRenderPaths } from "./utils/paths.js";
import { defaultLogger, type ProducerLogger } from "./logger.js";
import { Semaphore } from "./utils/semaphore.js";
import { parseFps, normalizeResolutionFlag, type CanvasResolution } from "@hyperframes/core";
import {
parseFps,
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
type CanvasResolution,
} from "@hyperframes/core";
// ---------------------------------------------------------------------------
// Types
@@ -93,9 +98,17 @@ interface RenderInput {
* Output resolution preset (e.g. `landscape-4k`). Drives the same
* `resolveDeviceScaleFactor` supersampling path the local CLI uses Chrome
* renders at a higher devicePixelRatio so the captured screenshot lands at
* the requested dimensions. Aspect ratio must match the composition.
* the requested dimensions. Aspect ratio must match the composition unless
* `outputResolutionAspectAgnostic` is set (see below).
*/
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` was normalized from an aspect-agnostic alias
* (`1080p`, `hd`, `4k`, `uhd`). The compile stage will adapt the preset to
* the composition's orientation instead of rejecting portrait/square
* compositions as an aspect-ratio mismatch.
*/
outputResolutionAspectAgnostic?: boolean;
}
interface PreparedRenderInput {
@@ -151,7 +164,8 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
? body.videoFrameFormat
: undefined;
const { variables, outputResolution } = parseRenderOverrides(body);
const { variables, outputResolution, outputResolutionAspectAgnostic } =
parseRenderOverrides(body);
return {
outputPath,
@@ -165,6 +179,7 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
format,
variables,
outputResolution,
outputResolutionAspectAgnostic,
videoFrameFormat,
};
}
@@ -182,15 +197,23 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
function parseRenderOverrides(body: Record<string, unknown>): {
variables?: Record<string, unknown>;
outputResolution?: CanvasResolution;
outputResolutionAspectAgnostic?: boolean;
} {
// Only forward a plain JSON object. Arrays / primitives / null → undefined.
const variables = isPlainObject(body.variables) ? body.variables : undefined;
// Accept canonical presets and aliases ("4k", "landscape-4k", …).
const outputResolution =
typeof body.outputResolution === "string"
? normalizeResolutionFlag(body.outputResolution)
: undefined;
return { variables, outputResolution };
const rawOutputResolution =
typeof body.outputResolution === "string" ? body.outputResolution : undefined;
const outputResolution = rawOutputResolution
? normalizeResolutionFlag(rawOutputResolution)
: undefined;
// Preserve the "raw shape was tier-only" signal so the compile stage can
// adapt the preset to the composition's orientation. Set only when
// normalization succeeded — a bad string doesn't need the flag.
const outputResolutionAspectAgnostic = outputResolution
? isAspectAgnosticResolutionAlias(rawOutputResolution)
: undefined;
return { variables, outputResolution, outputResolutionAspectAgnostic };
}
/**
@@ -210,6 +233,7 @@ function buildRenderJobConfig(input: RenderInput, log: ProducerLogger) {
entryFile: input.entryFile,
variables: input.variables,
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
videoFrameFormat: input.videoFrameFormat,
logger: log,
};
@@ -128,6 +128,12 @@ export interface DistributedRenderConfig {
videoFrameFormat?: VideoFrameFormat;
/** Output resolution preset; engages Chrome `deviceScaleFactor` supersampling. */
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` was normalized from an aspect-agnostic alias
* (`1080p`, `hd`, `4k`, `uhd`) the compile stage re-targets the preset
* to the composition's orientation.
*/
outputResolutionAspectAgnostic?: boolean;
/**
* Frames per chunk. When explicitly set, that value is used and
@@ -762,6 +768,7 @@ export async function plan(
bitrate: config.bitrate,
videoFrameFormat: config.videoFrameFormat,
outputResolution: config.outputResolution,
outputResolutionAspectAgnostic: config.outputResolutionAspectAgnostic,
// HDR is banned in distributed mode. force-sdr keeps the
// extract / encoder paths off the HDR branches entirely.
hdrMode: config.hdrMode ?? "force-sdr",
@@ -99,6 +99,7 @@ export interface SyntheticRenderJobInput {
bitrate?: string;
videoFrameFormat?: VideoFrameFormat;
outputResolution?: RenderConfig["outputResolution"];
outputResolutionAspectAgnostic?: RenderConfig["outputResolutionAspectAgnostic"];
hdrMode: RenderConfig["hdrMode"];
strictness?: RenderConfig["strictness"];
entryFile: string;
@@ -120,6 +121,7 @@ export function buildSyntheticRenderJob(input: SyntheticRenderJobInput): RenderJ
videoBitrate: input.bitrate,
videoFrameFormat: input.videoFrameFormat,
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
// Distributed mode hard-pins to software GPU. The plan-time validator
// refuses to fan out otherwise.
useGpu: false,
@@ -22,7 +22,12 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { EngineConfig } from "@hyperframes/engine";
import { runCompileStage, type CompileStageInput } from "./compileStage.js";
import type { CanvasResolution } from "@hyperframes/core";
import {
runCompileStage,
type CompileStageInput,
type CompileStageResult,
} from "./compileStage.js";
import type { RenderJob } from "../../renderOrchestrator.js";
const noopLog = {
@@ -70,12 +75,13 @@ function createCfg(overrides: Partial<EngineConfig> = {}): EngineConfig {
};
}
function createJob(): RenderJob {
function createJob(overrides: Partial<RenderJob["config"]> = {}): RenderJob {
return {
id: "test-job",
config: {
fps: { num: 30, den: 1 },
quality: "standard",
...overrides,
},
status: "queued",
progress: 0,
@@ -106,6 +112,20 @@ const IFRAME_HTML = `<!doctype html>
</body>
</html>`;
// Portrait/square/landscape fixture template — same shape as PLAIN_HTML but
// with caller-supplied composition dimensions. Consumed by the aspect-agnostic
// re-map tests below (and reachable from any sibling describe block, unlike a
// helper scoped inside one describe).
const orientedHtml = (w: number, h: number): string => `<!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<div data-composition-id="root" data-width="${w}" data-height="${h}" data-duration="1">
<p>oriented composition</p>
</div>
</body>
</html>`;
interface CompileFixture {
workDir: string;
htmlPath: string;
@@ -211,3 +231,153 @@ describe("runCompileStage — forceScreenshot snapshot", () => {
}
});
});
/**
* Tests for the aspect-agnostic --resolution re-map in `runCompileStage`.
*
* Field signal ts=1784176662 (darwin/arm64, CLI 0.7.59):
* "--resolution 1080p rejects a 1080x1920 portrait comp — render at native."
*
* `--resolution 1080p` / `hd` / `4k` / `uhd` name a resolution *tier* without
* pinning an orientation. `normalizeResolutionFlag` maps them all to a
* landscape preset (backwards compat), and the CLI/server layers then flag
* the raw input as aspect-agnostic on `RenderConfig`. The compile stage
* consults that flag before `resolveDeviceScaleFactor` if the composition's
* orientation differs from the preset's, the preset is re-targeted to the
* matching sibling in the same tier (HD HD, 4K 4K). Explicit
* orientation-bearing presets stay strict.
*/
describe("runCompileStage — aspect-agnostic --resolution re-map", () => {
let fixture: CompileFixture | null = null;
afterEach(() => {
fixture?.cleanup();
fixture = null;
});
async function runResolutionCase(input: {
compWidth: number;
compHeight: number;
outputResolution: CanvasResolution;
aspectAgnostic: boolean;
}): Promise<CompileStageResult> {
fixture = setupFixture(orientedHtml(input.compWidth, input.compHeight));
const projectDir = join(fixture.workDir, "project");
const cfg = createCfg();
const stageInput: CompileStageInput = {
projectDir,
workDir: fixture.workDir,
htmlPath: fixture.htmlPath,
entryFile: "index.html",
job: createJob({
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.aspectAgnostic,
}),
cfg,
needsAlpha: false,
log: noopLog,
assertNotAborted: () => {},
};
return runCompileStage(stageInput);
}
// ─── Positive branches: aspect-agnostic auto-flip ──────────────────────
it("landscape composition + aspect-agnostic `landscape` preset → no re-map, DPR=1", async () => {
// Sanity: the flag was ambiguous but the composition IS landscape, so
// the preset already matches — nothing to flip.
const result = await runResolutionCase({
compWidth: 1920,
compHeight: 1080,
outputResolution: "landscape",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(1);
expect(result.outputWidth).toBe(1920);
expect(result.outputHeight).toBe(1080);
});
it("portrait composition + aspect-agnostic `landscape` preset → re-maps to portrait, DPR=1 (field signal scenario)", async () => {
// The reporter's exact case: --resolution 1080p (normalized landscape)
// on a 1080×1920 portrait comp. Previously threw "aspect ratio does not
// match"; now re-maps to portrait (1080×1920) and DPR resolves to 1.
const result = await runResolutionCase({
compWidth: 1080,
compHeight: 1920,
outputResolution: "landscape",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(1);
expect(result.outputWidth).toBe(1080);
expect(result.outputHeight).toBe(1920);
});
it("square composition + aspect-agnostic `landscape` preset → re-maps to square, DPR=1", async () => {
// --resolution 1080p on a 1080×1080 square comp → renders at 1080×1080.
const result = await runResolutionCase({
compWidth: 1080,
compHeight: 1080,
outputResolution: "landscape",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(1);
expect(result.outputWidth).toBe(1080);
expect(result.outputHeight).toBe(1080);
});
it("portrait composition + aspect-agnostic `landscape-4k` preset → re-maps to portrait-4k, preserves 4K tier", async () => {
// `--resolution 4k` (normalized landscape-4k) on a portrait comp: the
// re-map picks portrait-4k (same tier) rather than downgrading to
// portrait (HD). 1080×1920 comp × 2 = 2160×3840 (portrait-4k).
const result = await runResolutionCase({
compWidth: 1080,
compHeight: 1920,
outputResolution: "landscape-4k",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(2);
expect(result.outputWidth).toBe(2160);
expect(result.outputHeight).toBe(3840);
});
it("square composition + aspect-agnostic `landscape-4k` preset → re-maps to square-4k, preserves 4K tier", async () => {
const result = await runResolutionCase({
compWidth: 1080,
compHeight: 1080,
outputResolution: "landscape-4k",
aspectAgnostic: true,
});
expect(result.deviceScaleFactor).toBe(2);
expect(result.outputWidth).toBe(2160);
expect(result.outputHeight).toBe(2160);
});
// ─── Negative branch: explicit preset stays strict ─────────────────────
it("portrait composition + explicit `landscape` preset (NOT aspect-agnostic) still throws aspect-mismatch", async () => {
// The user typed `--resolution landscape` explicitly, not an alias.
// Their stated intent is landscape orientation, which the renderer
// cannot produce from a portrait composition (deviceScaleFactor can't
// change aspect). Keep the actionable error rather than silently
// swapping orientation.
await expect(
runResolutionCase({
compWidth: 1080,
compHeight: 1920,
outputResolution: "landscape",
aspectAgnostic: false,
}),
).rejects.toThrow(/aspect ratio|--resolution portrait/i);
});
it("portrait composition + explicit `landscape-4k` preset (NOT aspect-agnostic) still throws", async () => {
await expect(
runResolutionCase({
compWidth: 1080,
compHeight: 1920,
outputResolution: "landscape-4k",
aspectAgnostic: false,
}),
).rejects.toThrow(/aspect ratio|--resolution portrait/i);
});
});
@@ -37,6 +37,7 @@
import { join } from "node:path";
import type { EngineConfig } from "@hyperframes/engine";
import { suggestMatchingPreset } from "@hyperframes/core";
import type { CompiledComposition } from "../../htmlCompiler.js";
import { compileForRender } from "../../htmlCompiler.js";
import type { ProducerLogger } from "../../../logger.js";
@@ -273,10 +274,35 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
height: compiled.height,
};
const { width, height } = composition;
// Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`) all
// normalize to a landscape preset up-front (see `normalizeResolutionFlag`),
// which was historically fine because 16:9 was the only shipped orientation.
// Once portrait + square presets landed, that early normalization started
// rejecting portrait/square compositions with a cryptic "aspect ratio does
// not match" from `resolveDeviceScaleFactor` — a common enough hit that a
// field report (CLI 0.7.59) surfaced it. When the flag was aspect-agnostic,
// re-target the preset to the sibling that matches the composition's
// orientation while preserving the tier (HD vs 4K). Explicit
// orientation-bearing presets stay strict — a `--resolution portrait` on a
// landscape composition still errors, honoring the user's stated intent.
const requestedResolution = job.config.outputResolution;
let effectiveResolution = requestedResolution;
if (requestedResolution && job.config.outputResolutionAspectAgnostic) {
const flipped = suggestMatchingPreset(width, height, requestedResolution);
if (flipped && flipped !== requestedResolution) {
log.info("Adapted aspect-agnostic --resolution to composition orientation", {
compositionWidth: width,
compositionHeight: height,
requestedResolution,
effectiveResolution: flipped,
});
effectiveResolution = flipped;
}
}
const deviceScaleFactor = resolveDeviceScaleFactor({
compositionWidth: width,
compositionHeight: height,
outputResolution: job.config.outputResolution,
outputResolution: effectiveResolution,
hdrRequested: job.config.hdrMode === "force-hdr",
alphaRequested: needsAlpha,
});
@@ -286,7 +312,7 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
log.info("Supersampling composition via deviceScaleFactor", {
compositionWidth: width,
compositionHeight: height,
outputResolution: job.config.outputResolution,
outputResolution: effectiveResolution,
outputWidth,
outputHeight,
deviceScaleFactor,
@@ -344,6 +344,21 @@ export interface RenderConfig {
* HDR constraints.
*/
outputResolution?: CanvasResolution;
/**
* True when `outputResolution` was normalized from an aspect-agnostic alias
* (`1080p`, `hd`, `4k`, `uhd`) rather than a preset that names its own
* orientation (`landscape`, `portrait`, `1080p-portrait`, ). Set by the
* CLI + server layers via `isAspectAgnosticResolutionAlias(rawInput)` at
* flag/body parse time.
*
* When true, the compile stage adapts the preset to the composition's
* orientation before calling `resolveDeviceScaleFactor` a portrait
* 1080×1920 composition with `--resolution 1080p` (normalized to
* `landscape`) is re-mapped to `portrait`, honoring the user's intent
* ("render at 1080p") without forcing them to know the aspect-suffixed
* alias (`1080p-portrait`). Explicit orientation presets stay strict.
*/
outputResolutionAspectAgnostic?: boolean;
}
export interface RenderPerfSummary {