mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +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",
|
||||
});
|
||||
|
||||
@@ -179,7 +179,9 @@ export {
|
||||
CANVAS_DIMENSIONS,
|
||||
VALID_CANVAS_RESOLUTIONS,
|
||||
normalizeResolutionFlag,
|
||||
isAspectAgnosticResolutionAlias,
|
||||
checkOutputResolutionCompatibility,
|
||||
suggestMatchingPreset,
|
||||
COMPOSITION_VARIABLE_TYPES,
|
||||
TIMELINE_COLORS,
|
||||
DEFAULT_DURATIONS,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user