mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user