fix(cli): wire aspect-agnostic resolution through cloudrun/lambda/batch + preflight recompute

Addresses R2 CHANGES_REQUESTED from Miga + Rames on PR #2529:

1. Sibling-surface gap (blocker): `hyperframes cloudrun render{,-batch}`,
   `hyperframes lambda render{,-batch}` all advertised the same tier-only
   aliases (`1080p` / `hd` / `4k` / `uhd`) but normalized them to `landscape`
   and never set `outputResolutionAspectAgnostic`. The distributed plumbing
   PR #2529 added received `undefined` from those callers, so portrait `1080p`
   still hit the original aspect-mismatch on Cloud Run / Lambda.

   Fix: introduce `resolveResolutionFlagPair` in `@hyperframes/parsers` (the
   single source of truth for the two-step normalize + aspect-agnostic
   detect) and route every distributed entrypoint through a shared
   `parseOutputResolutionFlag` CLI util so the alias signal now reaches
   `SerializableDistributedRenderConfig`. Studio Server keeps its
   canonical-only HTTP contract; that intent is now pinned in tests.

2. Preflight recompute (hardening): the earlier "downgrade aspect-mismatch"
   preflight cleared un-remapped mismatches, so IG 4:5 (non-preset aspect,
   no sibling) and portrait-4K comp + `--resolution 1080p` (remap +
   downsample) both slipped through to fail late in `resolveDeviceScaleFactor`.
   Now `checkRenderResolutionPreflight` computes the effective preset via
   `suggestMatchingPreset` (mirroring the compile stage's
   `adaptAspectAgnosticResolution`) and re-checks against that — only
   genuinely-fixable mismatches clear early. New tests pin both regressed
   input classes.

3. Docker forwarding boundary test (Miga's important #2): pinned
   `1080p` survives verbatim as `--resolution 1080p` in the Docker args
   so the in-container CLI can re-run `isAspectAgnosticResolutionAlias`.

4. Doc-nit (Miga): parsers/src/types.ts no longer references the
   nonexistent `resolveResolutionForComposition` — points at the actual
   remap helpers.

Fallow: cloudrun.ts / lambda.ts share 390 lines of pre-existing structural
symmetry (parallel AWS + GCP dispatchers), and lambda/render.ts +
render-batch.ts declare parallel RenderArgs interfaces. Both re-flagged
after threading the aspect-agnostic field through each surface; ignored
with justification in .fallowrc.jsonc. lambda.ts's `run` and
lambda/render.ts's `waitForCompletion` are pre-existing CRAP-score
hotspots untouched by this PR — added under health.ignore.

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

— Via
This commit is contained in:
Via
2026-07-16 08:01:56 +00:00
co-authored by Claude
parent 7e58d050f8
commit 2d398ed274
18 changed files with 707 additions and 66 deletions
@@ -0,0 +1,66 @@
/**
* Boundary test for the `hyperframes cloudrun render{,-batch}` wire config.
* Pins that the aspect-agnostic flag reaches `SerializableDistributedRenderConfig`
* so the Cloud Run worker's compile stage can remap `landscape` → `portrait`.
*
* The parse helper itself is covered at `../utils/parseOutputResolution.test.ts`;
* we only re-check the entrypoint composition here.
*/
import { describe, expect, it } from "vitest";
import { buildRenderConfig } from "./cloudrun.js";
describe("cloudrun wire config — aspect-agnostic threading", () => {
const baseArgs: Record<string, unknown> = {
format: "mp4",
codec: undefined,
quality: undefined,
"chunk-size": undefined,
"max-parallel-chunks": undefined,
"target-chunk-frames": undefined,
};
it("threads outputResolutionAspectAgnostic=true through the wire config for portrait 1080p", () => {
// The exact bug shape: portrait comp + `--output-resolution 1080p`.
// Before the fix, the alias signal never reached the wire; Cloud Run
// then hit the same portrait rejection this PR set out to eliminate.
const config = buildRenderConfig(
{ ...baseArgs, "output-resolution": "1080p" },
30,
1080,
1920,
undefined,
);
expect(config.outputResolution).toBe("landscape");
expect(config.outputResolutionAspectAgnostic).toBe(true);
});
it("keeps the aspect-agnostic key absent when the flag is a canonical preset", () => {
// Sparse-wire invariant: don't broadcast `false` for the common path —
// the compile-stage remap only fires on `true`.
const config = buildRenderConfig(
{ ...baseArgs, "output-resolution": "portrait-4k" },
30,
2160,
3840,
undefined,
);
expect(config.outputResolution).toBe("portrait-4k");
expect(config).not.toHaveProperty("outputResolutionAspectAgnostic");
});
it("omits both resolution fields when --output-resolution is unset", () => {
const config = buildRenderConfig(baseArgs, 30, 1920, 1080, undefined);
expect(config).not.toHaveProperty("outputResolution");
expect(config).not.toHaveProperty("outputResolutionAspectAgnostic");
});
it("preserves the surface-labeled strict-throw contract on unknown values", () => {
// Sanity guard: the local delegate stays wired to the shared helper's
// throw semantics rather than silently downgrading to undefined. Full
// input-space coverage lives at `../utils/parseOutputResolution.test.ts`.
expect(() =>
buildRenderConfig({ "output-resolution": "8k" }, 30, 1920, 1080, undefined),
).toThrow(/\[cloudrun render\]/);
});
});
+28 -15
View File
@@ -18,11 +18,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { defineCommand } from "citty";
import {
type CanvasResolution,
normalizeResolutionFlag,
VALID_CANVAS_RESOLUTIONS,
} from "@hyperframes/core";
import { type CanvasResolution } from "@hyperframes/core";
import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import {
@@ -774,13 +771,21 @@ function runDestroy(args: Record<string, unknown>): void {
* separately (it differs per batch entry). Mirrors the local `hyperframes
* render` flag surface so the two stay consistent.
*/
function buildRenderConfig(
/**
* Exported for unit-test coverage of the aspect-agnostic wire shape — the
* portrait-1080p sibling-surface regression that shipped in v0.7.60 landed
* here because this builder dropped the tier-alias signal on the floor.
*/
export function buildRenderConfig(
args: Record<string, unknown>,
fps: number,
width: number,
height: number,
variables: Record<string, unknown> | undefined,
): Record<string, unknown> {
const { outputResolution, outputResolutionAspectAgnostic } = parseOutputResolution(
args["output-resolution"],
);
return stripUndefined({
fps,
width,
@@ -791,7 +796,11 @@ function buildRenderConfig(
chunkSize: parsePositiveInt(args["chunk-size"], "--chunk-size"),
maxParallelChunks: parsePositiveInt(args["max-parallel-chunks"], "--max-parallel-chunks"),
targetChunkFrames: parsePositiveInt(args["target-chunk-frames"], "--target-chunk-frames"),
outputResolution: parseOutputResolution(args["output-resolution"]),
outputResolution,
// Set only when true so the wire shape stays sparse for the common
// canonical-preset path (matches how the flag flows through
// `SerializableDistributedRenderConfig` from every other emitter).
outputResolutionAspectAgnostic: outputResolutionAspectAgnostic ? true : undefined,
variables,
});
}
@@ -823,14 +832,18 @@ function resolveAndValidateVariables(
return variables;
}
function parseOutputResolution(raw: unknown): CanvasResolution | undefined {
if (raw == null || raw === "") return undefined;
const normalized = normalizeResolutionFlag(String(raw));
if (normalized) return normalized;
throw new Error(
`[cloudrun render] --output-resolution must be one of ${VALID_CANVAS_RESOLUTIONS.join("|")} ` +
`(or an alias: 1080p, 4k, uhd, hd, …); got ${String(raw)}`,
);
/**
* Cloud Run flavor of the shared {@link parseOutputResolutionFlag} — carries
* the aspect-agnostic signal through so `SerializableDistributedRenderConfig`
* can trigger the compile-stage remap. The runtime work lives in the shared
* util; wire-config-level coverage lives at `cloudrun.test.ts`, and full
* input-space coverage at `../utils/parseOutputResolution.test.ts`.
*/
function parseOutputResolution(raw: unknown): {
outputResolution: CanvasResolution | undefined;
outputResolutionAspectAgnostic: boolean;
} {
return parseOutputResolutionFlag(raw, { surfaceLabel: "[cloudrun render]" });
}
// ── parse helpers ─────────────────────────────────────────────────────────
+27 -15
View File
@@ -10,11 +10,8 @@
import { defineCommand } from "citty";
import type { DistributedFormat } from "@hyperframes/aws-lambda/sdk";
import {
type CanvasResolution,
VALID_CANVAS_RESOLUTIONS,
normalizeResolutionFlag,
} from "@hyperframes/core";
import { type CanvasResolution } from "@hyperframes/core";
import { parseOutputResolutionFlag } from "../utils/parseOutputResolution.js";
import type { Example } from "./_examples.js";
import { c } from "../ui/colors.js";
import { readAllowedCompositionFpsFromDir } from "../utils/compositionFps.js";
@@ -307,6 +304,7 @@ export default defineCommand({
process.exit(1);
}
const { runRender } = await import("./lambda/render.js");
const renderResolution = parseOutputResolution(args["output-resolution"]);
await runRender({
projectDir,
stackName,
@@ -314,7 +312,8 @@ export default defineCommand({
fps: fpsRaw,
width,
height,
outputResolution: parseOutputResolution(args["output-resolution"]),
outputResolution: renderResolution.outputResolution,
outputResolutionAspectAgnostic: renderResolution.outputResolutionAspectAgnostic,
format: parseFormat(args.format),
codec: parseCodec(args.codec),
quality: parseQuality(args.quality),
@@ -362,6 +361,7 @@ export default defineCommand({
process.exit(1);
}
const { runRenderBatch } = await import("./lambda/render-batch.js");
const batchResolution = parseOutputResolution(args["output-resolution"]);
await runRenderBatch({
projectDir,
stackName,
@@ -370,7 +370,8 @@ export default defineCommand({
fps: fpsRaw,
width,
height,
outputResolution: parseOutputResolution(args["output-resolution"]),
outputResolution: batchResolution.outputResolution,
outputResolutionAspectAgnostic: batchResolution.outputResolutionAspectAgnostic,
format: parseFormat(args.format),
codec: parseCodec(args.codec),
quality: parseQuality(args.quality),
@@ -481,12 +482,23 @@ const parseQuality = (raw: unknown): (typeof QUALITIES)[number] | undefined =>
const parseChromeSource = (raw: unknown): (typeof CHROME_SOURCES)[number] =>
parseEnum(raw, CHROME_SOURCES, "[lambda deploy] --chrome-source", "sparticuz")!;
function parseOutputResolution(raw: unknown): CanvasResolution | undefined {
if (raw == null || raw === "") return undefined;
const normalized = normalizeResolutionFlag(String(raw));
if (normalized) return normalized;
throw new Error(
`[lambda render] --output-resolution must be one of ${VALID_CANVAS_RESOLUTIONS.join("|")} ` +
`(or an alias: 1080p, 4k, uhd, hd, 1080p-portrait, portrait-1080p, 4k-portrait, 1080p-square, square-1080p, 4k-square); got ${String(raw)}`,
);
/**
* Lambda flavor of the shared {@link parseOutputResolutionFlag} — same wire
* contract as the Cloud Run counterpart. Runtime work lives in the shared
* util; wire-config-level coverage lives at `./lambda/render.test.ts` /
* `./lambda/render-batch.test.ts`, and full input-space coverage at
* `../utils/parseOutputResolution.test.ts`.
*/
function parseOutputResolution(raw: unknown): {
outputResolution: CanvasResolution | undefined;
outputResolutionAspectAgnostic: boolean;
} {
return parseOutputResolutionFlag(raw, {
surfaceLabel: "[lambda render]",
// The Lambda `--output-resolution` help text advertises the full alias
// list (tier-only + orientation-suffixed) — keep the error message
// faithful to that surface's docs.
aliasHint:
"1080p, 4k, uhd, hd, 1080p-portrait, portrait-1080p, 4k-portrait, 1080p-square, square-1080p, 4k-square",
});
}
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseBatchFile, runWithConcurrencyLimit } from "./render-batch.js";
import {
buildLambdaBatchRenderConfig,
parseBatchFile,
runWithConcurrencyLimit,
type RenderBatchArgs,
} from "./render-batch.js";
let tmpDir: string;
@@ -137,3 +142,38 @@ describe("parseBatchFile", () => {
expectExitOne('{"outputKey":"renders/a.mp4","variables":[1,2,3]}\n');
});
});
// See `../cloudrun.test.ts` / `./render.test.ts` for the sibling wire-config
// coverage. Repeating it at every entrypoint is deliberate: cross-scaffold
// drift is exactly what shipped PR #2529 R2 CHANGES_REQUESTED.
describe("buildLambdaBatchRenderConfig — aspect-agnostic wire threading", () => {
const baseArgs: RenderBatchArgs = {
projectDir: "/tmp/hf-batch",
stackName: "hf-test",
batch: "/tmp/batch.jsonl",
fps: 30,
width: 1080,
height: 1920,
format: "mp4",
json: false,
};
it("threads outputResolutionAspectAgnostic=true through for portrait 1080p", () => {
// The batch entrypoint fans out N Step Functions executions from a
// single wire config, so a dropped alias flag multiplies into N broken
// renders — pin it here.
const config = buildLambdaBatchRenderConfig({
...baseArgs,
outputResolution: "landscape",
outputResolutionAspectAgnostic: true,
});
expect(config.outputResolution).toBe("landscape");
expect(config.outputResolutionAspectAgnostic).toBe(true);
});
it("keeps the aspect-agnostic key absent when the flag is a canonical preset", () => {
const config = buildLambdaBatchRenderConfig({ ...baseArgs, outputResolution: "portrait-4k" });
expect(config.outputResolution).toBe("portrait-4k");
expect(config.outputResolutionAspectAgnostic).toBeUndefined();
});
});
@@ -65,6 +65,13 @@ export interface RenderBatchArgs {
height: number;
/** See {@link RenderArgs.outputResolution}. */
outputResolution?: CanvasResolution;
/**
* See {@link RenderArgs.outputResolutionAspectAgnostic}. Threaded through
* `SerializableDistributedRenderConfig` so the Lambda worker's compile
* stage remaps aspect-agnostic aliases (`1080p` / `hd` / `4k` / `uhd`) to
* the composition's orientation — same regression class as the local CLI.
*/
outputResolutionAspectAgnostic?: boolean;
format: DistributedFormat;
codec?: "h264" | "h265";
quality?: "draft" | "standard" | "high";
@@ -209,19 +216,7 @@ export async function runRenderBatch(args: RenderBatchArgs): Promise<void> {
process.exit(1);
}
const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
height: args.height,
outputResolution: args.outputResolution,
format: args.format,
codec: args.codec,
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
};
const config: SerializableDistributedRenderConfig = buildLambdaBatchRenderConfig(args);
// Deploy the site once and reuse it across every entry. --site-id and
// --dry-run both skip the deploy via a synthesised handle.
@@ -350,6 +345,34 @@ function makePlaceholderSiteHandle(siteId: string, bucketName: string): SiteHand
};
}
/**
* Build the shared wire {@link SerializableDistributedRenderConfig} used
* for every entry in a `hyperframes lambda render-batch` run. Extracted for
* boundary-test coverage of the aspect-agnostic flag threading (per-entry
* `variables` are still overlayed at dispatch time inside {@link runRenderBatch}).
*/
export function buildLambdaBatchRenderConfig(
args: RenderBatchArgs,
): SerializableDistributedRenderConfig {
const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
height: args.height,
outputResolution: args.outputResolution,
format: args.format,
codec: args.codec,
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
};
if (args.outputResolutionAspectAgnostic) {
config.outputResolutionAspectAgnostic = true;
}
return config;
}
/**
* Read the JSONL batch file and return one parsed entry per non-blank
* line. Reads the whole file into memory — fine for typical batch sizes,
@@ -0,0 +1,57 @@
/**
* Boundary tests for the wire config emitted by `hyperframes lambda render`.
* Pins that the aspect-agnostic resolution flag survives all the way into
* `SerializableDistributedRenderConfig`, which is what the Lambda worker's
* compile stage reads before remapping `landscape` → `portrait` for an
* aspect-agnostic alias like `--output-resolution 1080p`.
*
* See `../cloudrun.test.ts` for the parseOutputResolution counterpart and
* `../render.test.ts` for the local-CLI preflight guardrails.
*/
import { describe, expect, it } from "vitest";
import { buildLambdaRenderConfig, type RenderArgs } from "./render.js";
const BASE_ARGS: RenderArgs = {
projectDir: "/tmp/hf-project",
stackName: "hf-test",
fps: 30,
width: 1080,
height: 1920,
format: "mp4",
json: false,
wait: false,
waitIntervalMs: 5000,
};
describe("buildLambdaRenderConfig — aspect-agnostic wire threading", () => {
it("threads outputResolutionAspectAgnostic=true through for portrait 1080p", () => {
// The exact bug shape: portrait 1080×1920 comp + `--output-resolution 1080p`.
// Before the fix, the aspect-agnostic flag never reached the wire, so
// the Lambda worker saw the explicit `landscape` preset and rejected
// the portrait comp — reproducing the local-CLI regression on the
// distributed path.
const config = buildLambdaRenderConfig(
{ ...BASE_ARGS, outputResolution: "landscape", outputResolutionAspectAgnostic: true },
undefined,
);
expect(config.outputResolution).toBe("landscape");
expect(config.outputResolutionAspectAgnostic).toBe(true);
});
it("keeps the aspect-agnostic key absent when the flag is a canonical preset", () => {
// Sparse-wire invariant: only forward `true`; canonical presets stay
// strict and don't need the compile-stage remap.
const config = buildLambdaRenderConfig(
{ ...BASE_ARGS, outputResolution: "portrait-4k" },
undefined,
);
expect(config.outputResolution).toBe("portrait-4k");
expect(config.outputResolutionAspectAgnostic).toBeUndefined();
});
it("carries variables verbatim through the wire config", () => {
const config = buildLambdaRenderConfig(BASE_ARGS, { alice: "hello" });
expect(config.variables).toEqual({ alice: "hello" });
});
});
+43 -14
View File
@@ -42,6 +42,15 @@ export interface RenderArgs {
* from a 1920×1080 layout uses DPR 2, etc.
*/
outputResolution?: CanvasResolution;
/**
* True when {@link outputResolution} was normalized from an aspect-agnostic
* alias (`1080p` / `hd` / `4k` / `uhd`). Threaded through
* `SerializableDistributedRenderConfig` so the Lambda worker's compile stage
* remaps `landscape` `portrait` / `square` when the composition's
* orientation demands it. Dropping this field is what shipped the
* portrait-1080p regression at the sibling entrypoints (see PR #2529).
*/
outputResolutionAspectAgnostic?: boolean;
format: DistributedFormat;
codec?: "h264" | "h265";
quality?: "draft" | "standard" | "high";
@@ -114,20 +123,7 @@ export async function runRender(args: RenderArgs): Promise<void> {
}
}
const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
height: args.height,
outputResolution: args.outputResolution,
format: args.format,
codec: args.codec,
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
variables,
};
const config: SerializableDistributedRenderConfig = buildLambdaRenderConfig(args, variables);
// When the caller passes only --site-id, synthesise the minimum-shape
// SiteHandle pointing at the deterministic content-addressed key. The
@@ -233,3 +229,36 @@ async function waitForCompletion(
function sleep(ms: number): Promise<void> {
return new Promise((res) => setTimeout(res, ms));
}
/**
* Build the wire {@link SerializableDistributedRenderConfig} for
* `hyperframes lambda render`. Extracted for boundary-test coverage of the
* aspect-agnostic flag threading the field this helper preserves is what
* lets the Lambda worker's compile stage remap `landscape` `portrait` on
* an aspect-agnostic alias (`1080p` / `hd` / `4k` / `uhd`).
*/
export function buildLambdaRenderConfig(
args: RenderArgs,
variables: Record<string, unknown> | undefined,
): SerializableDistributedRenderConfig {
const config: SerializableDistributedRenderConfig = {
fps: args.fps,
width: args.width,
height: args.height,
outputResolution: args.outputResolution,
format: args.format,
codec: args.codec,
quality: args.quality,
chunkSize: args.chunkSize,
maxParallelChunks: args.maxParallelChunks,
targetChunkFrames: args.targetChunkFrames,
runtimeCap: "lambda",
variables,
};
// Keep the wire shape sparse when the alias flag wasn't set — matches how
// `SerializableDistributedRenderConfig` is emitted at every other adapter.
if (args.outputResolutionAspectAgnostic) {
config.outputResolutionAspectAgnostic = true;
}
return config;
}
+41
View File
@@ -1244,6 +1244,47 @@ describe("checkRenderResolutionPreflight", () => {
expect(result?.kind).toBe("aspect-mismatch");
expect(result?.message).toContain("--resolution portrait");
});
// Rames Δ2 on PR #2529: the earlier "downgrade aspect-mismatch to
// undefined" preflight cleared *un-remapped* mismatches, so two input
// classes below regressed from an early actionable error to a late throw
// deep in `resolveDeviceScaleFactor` (browser + ffmpeg already up).
// The fix computes the *effective* preset via `suggestMatchingPreset`
// (mirroring the compile stage) and re-checks against that, so only
// genuinely-fixable mismatches clear early.
it("blocks IG 4:5 (non-preset aspect) early with an aspect-aware message", async () => {
// 1080×1350 is a 4:5 portrait — no canonical preset shares that aspect,
// so `suggestMatchingPreset` returns undefined and `adaptAspectAgnosticResolution`
// keeps the original preset. Before the fix, aspect-agnostic downgraded
// the mismatch here to undefined; now the preflight surfaces it early.
const result = await checkRenderResolutionPreflight(comp(1080, 1350), "landscape", agnostic);
expect(result?.kind).toBe("aspect-mismatch");
// No sibling preset to suggest → message falls back to the "pick a preset
// whose orientation matches" hint (see `buildAspectMismatch` in
// `@hyperframes/parsers/outputResolutionCompatibility`).
expect(result?.message).toMatch(/preset whose orientation matches|omit --resolution/i);
});
it("blocks a portrait-4K comp + `1080p` downsample early (orientation-flip masks the tier gap)", async () => {
// 2160×3840 (portrait 4K) + `--resolution 1080p` — the compile stage
// remaps `landscape` → `portrait` (1080×1920), and *then* the preset
// is smaller than the composition. The un-remapped preflight let this
// slip through as an aspect-mismatch downgrade; the remap-then-check
// catches the real failure — downsampling — early.
const result = await checkRenderResolutionPreflight(comp(2160, 3840), "landscape", agnostic);
expect(result?.kind).toBe("downsampling");
});
it("blocks a portrait 720p comp + `1080p` non-integer upscale early (orientation-flip masks the fractional DPR)", async () => {
// 720×1280 (portrait 720p) + `--resolution 1080p` — remap `landscape`
// → `portrait` (1080×1920). widthRatio = 1080 / 720 = 1.5, which
// `resolveDeviceScaleFactor` rejects. Surfacing it in preflight beats
// failing after Chrome + ffmpeg spin up. Same class as Miga's
// important note on PR #2529.
const result = await checkRenderResolutionPreflight(comp(720, 1280), "landscape", agnostic);
expect(result?.kind).toBe("non-integer-scale");
});
});
});
+24 -6
View File
@@ -92,6 +92,7 @@ import {
normalizeResolutionFlag,
isAspectAgnosticResolutionAlias,
checkOutputResolutionCompatibility,
suggestMatchingPreset,
parseFps,
fpsToNumber,
fpsToFfmpegArg,
@@ -1216,20 +1217,37 @@ export async function checkRenderResolutionPreflight(
// Couldn't determine the composition's actual dimensions — defer to the
// pipeline's own defense-in-depth check rather than guess.
if (!dims) return undefined;
// Aspect-agnostic aliases (`--resolution 1080p` / `hd` / `4k` / `uhd`)
// don't nail an orientation — the compile stage remaps them to the
// composition's orientation via `adaptAspectAgnosticResolution` +
// `suggestMatchingPreset`. Mirror that remap here BEFORE running the
// compatibility check so the early rejection reflects the *effective*
// preset the pipeline will actually use.
//
// Doing this pre-check (rather than post-hoc "downgrade aspect-mismatch")
// is what makes the following cases fail early with an aspect-aware
// message instead of throwing deep in the compile stage after Chrome +
// ffmpeg have already spun up (Rames Δ2 on PR #2529):
//
// - Non-preset aspect (e.g. IG 4:5 1080×1350): no sibling preset
// matches → `suggestMatchingPreset` returns `undefined` → we keep the
// original preset and surface the aspect-mismatch normally.
// - Orientation-flip + tier-too-small (portrait-4K comp 2160×3840 +
// `--resolution 1080p`): remaps `landscape` → `portrait` (1080×1920),
// re-check catches the downsample early with a clear message.
const effective =
modes.aspectAgnostic === true
? (suggestMatchingPreset(dims.width, dims.height, outputResolution) ?? outputResolution)
: outputResolution;
const compat = checkOutputResolutionCompatibility({
compositionWidth: dims.width,
compositionHeight: dims.height,
outputResolution,
outputResolution: effective,
alphaRequested: modes.alphaRequested,
hdrRequested: modes.hdrRequested,
});
// 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 };
}