Files
hyperframes/packages/parsers/src/outputResolutionCompatibility.ts
T
ViaandClaude 46e9ecf3f2 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
2026-07-16 06:23:14 +00:00

205 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared "is this `outputResolution` preset compatible with the composition?"
* check.
*
* The `--resolution` render flag (a `CanvasResolution` preset) is chosen
* independently of the composition it renders, so a portrait composition
* (1080×1920) rendered with `--resolution landscape` (1920×1080) is a common
* mistake — especially from AI agents that pick a preset by habit rather than
* by inspecting the composition. Historically this surfaced as a cryptic
* `Error` thrown deep inside the render compiler (`resolveDeviceScaleFactor`
* in `@hyperframes/producer`), after the browser and ffmpeg had already spun
* up, with a message that named the mismatch but not the fix.
*
* This module gives every consumer (the render pre-flight in the CLI, the
* producer's `resolveDeviceScaleFactor`, and any future lint rule) a single,
* dependency-free definition of "is this preset usable for this composition"
* — including a suggested preset when there's an unambiguously-correct swap —
* so the same check can run *before* a render is attempted (loud, actionable,
* cheap) and again as defense-in-depth inside the pipeline.
*
* It lives in `@hyperframes/parsers` (rather than `@hyperframes/core`) because
* both `core`/`producer` and `lint` may need it, and `lint` cannot depend on
* `core`. The geometry it needs (`CANVAS_DIMENSIONS`) already lives here.
*/
import { CANVAS_DIMENSIONS, VALID_CANVAS_RESOLUTIONS, type CanvasResolution } from "./types.js";
export type OutputResolutionIssueKind =
| "hdr-incompatible"
| "alpha-incompatible"
| "aspect-mismatch"
| "downsampling"
| "non-integer-scale";
export interface OutputResolutionCompatibility {
ok: boolean;
/** Present when `ok` is false. */
kind?: OutputResolutionIssueKind;
/** Human-readable, actionable message suitable for direct display. */
message?: string;
/**
* A preset whose orientation/aspect ratio matches the composition, when one
* exists and is an unambiguous swap for the user's intent. Present only for
* `aspect-mismatch`. Consumers may surface this as a suggestion ("did you
* mean `--resolution portrait`?"); it is intentionally *not* auto-applied —
* silently swapping a user-supplied flag changes their stated intent.
*/
suggestedResolution?: CanvasResolution;
}
const OK: OutputResolutionCompatibility = { ok: true };
/**
* Find the preset that shares the composition's aspect ratio and resolution
* tier (HD vs 4K) as the user's chosen preset. E.g. a portrait composition
* with `--resolution landscape-4k` suggests `portrait-4k`, not `portrait`,
* preserving the user's intent to render at 4K while fixing the orientation.
*
* 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.
*/
export function suggestMatchingPreset(
compositionWidth: number,
compositionHeight: number,
chosen: CanvasResolution,
): CanvasResolution | undefined {
const aspectMatches: CanvasResolution[] = VALID_CANVAS_RESOLUTIONS.filter((preset) => {
const { width, height } = CANVAS_DIMENSIONS[preset];
// Integer-safe aspect compare (cross-multiplication), matching the
// producer's mismatch check exactly.
return width * compositionHeight === height * compositionWidth;
});
if (aspectMatches.length === 0) return undefined;
// Prefer the aspect-matching preset in the same resolution tier as the chosen
// one so we don't silently downgrade 4K → HD (or vice versa). Tier is keyed
// off the `-4k` suffix rather than long-side pixels: a 4K *square* (2160×2160)
// has a shorter long side than 4K landscape (3840), so a pixel-based compare
// would fail to recognise `square-4k` as the 4K peer and downgrade to `square`.
const chosenIs4k = chosen.endsWith("-4k");
const sameTier = aspectMatches.find((preset) => preset.endsWith("-4k") === chosenIs4k);
return sameTier ?? aspectMatches[0];
}
function describeOrientation(width: number, height: number): string {
if (width > height) return "landscape";
if (width < height) return "portrait";
return "square";
}
/** Build the aspect-ratio-mismatch result, including a preset suggestion. */
function buildAspectMismatch(
compositionWidth: number,
compositionHeight: number,
outputResolution: CanvasResolution,
target: { width: number; height: number },
): OutputResolutionCompatibility {
const suggestedResolution = suggestMatchingPreset(
compositionWidth,
compositionHeight,
outputResolution,
);
const suggestion = suggestedResolution
? ` The composition is ${describeOrientation(compositionWidth, compositionHeight)} — ` +
`use --resolution ${suggestedResolution} instead.`
: ` Pick a preset whose orientation matches, or omit --resolution to render at the composition's native dimensions.`;
return {
ok: false,
kind: "aspect-mismatch",
suggestedResolution,
message:
`outputResolution ${outputResolution} (${target.width}×${target.height}) ` +
`does not match the aspect ratio of the composition ` +
`(${compositionWidth}×${compositionHeight}).` +
suggestion,
};
}
/**
* Check whether rendering a composition of the given dimensions with the given
* `outputResolution` preset (and alpha/HDR modes) is supported.
*
* Pure and dependency-free — the single source of truth for the constraints
* `resolveDeviceScaleFactor` enforces, so the CLI can run the exact same check
* as a pre-flight before any browser/ffmpeg work.
*
* @param outputResolution The chosen preset, or `undefined` when the render
* uses the composition's native dimensions (always compatible).
*/
export function checkOutputResolutionCompatibility(input: {
compositionWidth: number;
compositionHeight: number;
outputResolution: CanvasResolution | undefined;
alphaRequested?: boolean;
hdrRequested?: boolean;
}): OutputResolutionCompatibility {
const { compositionWidth, compositionHeight, outputResolution } = input;
if (!outputResolution) return OK;
if (input.hdrRequested) {
return {
ok: false,
kind: "hdr-incompatible",
message:
`outputResolution cannot be combined with hdrMode='force-hdr'. ` +
`HDR rendering composites at composition dimensions and does not yet ` +
`support supersampling. Pick one or render in two passes.`,
};
}
if (input.alphaRequested) {
return {
ok: false,
kind: "alpha-incompatible",
message:
`outputResolution cannot be combined with alpha output (--format webm|mov|png-sequence). ` +
`The alpha screenshot path does not yet apply deviceScaleFactor and would silently ` +
`produce composition-resolution frames. Render alpha at composition resolution and ` +
`upscale separately, or use --format mp4.`,
};
}
const target = CANVAS_DIMENSIONS[outputResolution];
// Aspect-ratio compare via cross-multiplication so the equality is integer-
// safe. Float division (`target.width / compositionWidth`) loses precision
// for non-power-of-2 ratios (e.g. cinema 4K 4096×2160 = 1.8963…) and a
// future preset could trip a false-mismatch on otherwise valid input.
if (target.width * compositionHeight !== target.height * compositionWidth) {
return buildAspectMismatch(compositionWidth, compositionHeight, outputResolution, target);
}
// Aspect ratios match → widthRatio === heightRatio. Compute once.
const widthRatio = target.width / compositionWidth;
if (widthRatio < 1) {
return {
ok: false,
kind: "downsampling",
message:
`outputResolution ${outputResolution} (${target.width}×${target.height}) ` +
`is smaller than the composition (${compositionWidth}×${compositionHeight}). ` +
`Downsampling via --resolution is not supported.`,
};
}
if (!Number.isInteger(widthRatio)) {
return {
ok: false,
kind: "non-integer-scale",
message:
`outputResolution ${outputResolution} requires a non-integer ` +
`device scale factor (${widthRatio}×) to upsample from ` +
`${compositionWidth}×${compositionHeight}. ` +
`Pick a preset that's an integer multiple, or rescale the composition.`,
};
}
return OK;
}