feat(cli): auto-detect aspect_ratio from composition dims when --aspect-ratio is omitted (#1145)

When the user runs `hyperframes cloud render` without `--aspect-ratio` and
the project source is a local directory, parse the entry HTML's root
`<div data-composition-id ...>` for `data-width` / `data-height` and pick
the supported aspect ratio that matches within ±0.05 tolerance:

- 16:9 (≈1.778) ← landscape 1920×1080, 4K 3840×2160, etc.
- 9:16 (≈0.563) ← portrait 1080×1920
- 1:1 (=1.0)    ← square 1080×1080

If the composition's ratio matches one of these, the CLI sets
`aspect_ratio` in the submit body and prints a one-line note
(`Detected aspect ratio: 9:16 (from index.html dims 1080×1920)`).

If the composition has no root div, no dims, or a ratio outside all three
tolerance bands (e.g. 4:5, 5:4, 21:9), the CLI logs a one-line warning
explaining the fallback and leaves `aspect_ratio` out of the submit body
— the server defaults to 16:9, and the user can pass `--aspect-ratio`
explicitly to override.

Explicit `--aspect-ratio` always wins. Detection is skipped for
`--asset-id` / `--url` project sources since the composition isn't on
disk; user gets a brief note in that case too.

New helper: `packages/cli/src/cloud/detectAspectRatio.ts` (pure regex
parse, no DOM library dep). 23 tests cover canonical matches, in-band
tolerance, all three non-match patterns (no root div, no dims, ratio out
of bands), and authoring edge cases (unquoted attrs, attribute order,
self-closing tags, multi-composition files).

Closes the `auto` carve-out flagged in ef#38182's deferred-scope note —
the CLI gets auto-detect without requiring a server-side zip-parse
capability (no API change).
This commit is contained in:
James Russo
2026-05-31 21:06:18 -04:00
committed by GitHub
parent 8e0b26dab6
commit 3c7e2f3649
3 changed files with 397 additions and 1 deletions
+77 -1
View File
@@ -26,6 +26,10 @@
import { defineCommand } from "citty";
import {
detectAspectRatioFromHtml,
type AspectRatioDetection,
} from "../../cloud/detectAspectRatio.js";
import { c } from "../../ui/colors.js";
import { errorBox, formatBytes, formatDuration } from "../../ui/format.js";
import { resolveProject } from "../../utils/project.js";
@@ -182,7 +186,7 @@ export default defineCommand({
const resolution = parseEnumFlag(args.resolution, VALID_RESOLUTION, {
flag: "--resolution",
});
const aspectRatio = parseEnumFlag(args["aspect-ratio"], VALID_ASPECT_RATIO, {
const explicitAspectRatio = parseEnumFlag(args["aspect-ratio"], VALID_ASPECT_RATIO, {
flag: "--aspect-ratio",
});
const pollIntervalMs = parsePollIntervalMs(args["poll-interval"]);
@@ -198,6 +202,14 @@ export default defineCommand({
url: args.url,
});
// When the user didn't pass --aspect-ratio explicitly AND the project is
// a local dir, parse the entry HTML's root composition div for
// data-width/data-height and pick the matching supported ratio. Saves
// the user from having to specify a value that's already implicit in
// the composition they authored. Explicit flag always wins.
const aspectRatio =
explicitAspectRatio ?? maybeAutoDetectAspectRatio(project, args.composition, asJson);
const variables = resolveVariablesAndValidateIfLocal(
args.variables,
args["variables-file"],
@@ -341,6 +353,70 @@ function resolveProjectInput(opts: {
return { kind: "dir", dir: opts.dir ?? "." };
}
/**
* Best-effort aspect-ratio detection for the cloud-render submit body when
* the user hasn't passed `--aspect-ratio`. Returns the detected value (one
* of `"16:9" | "9:16" | "1:1"`) or `undefined` to let the server's default
* (16:9) apply.
*
* Detection only fires when the project source is a local directory — for
* `--asset-id` and `--url` the composition zip isn't on disk and parsing
* it client-side isn't worth the extra fetch. The user gets a one-line
* note explaining the fallback.
*
* Logs to stdout in human-readable mode; suppressed in `--json` mode so the
* machine-readable output stays clean.
*/
// fallow-ignore-next-line complexity
function maybeAutoDetectAspectRatio(
project: ProjectInputSource,
compositionArg: string | undefined,
asJson: boolean,
): "16:9" | "9:16" | "1:1" | undefined {
if (project.kind !== "dir") {
const reason = project.kind === "asset_id" ? "--asset-id" : "--url";
logDetection(asJson, `Auto-detect skipped (project is ${reason})`);
return undefined;
}
const dir = project.dir ?? ".";
const entryRelative = compositionArg ?? "index.html";
const entryPath = resolvePath(dir, entryRelative);
const detection = detectAspectRatioFromHtml(entryPath);
logDetection(asJson, summarizeDetection(detection, entryRelative));
return detection.kind === "matched" ? detection.aspectRatio : undefined;
}
const ASPECT_FALLBACK_HINT =
"server will default aspect_ratio to 16:9. Pass --aspect-ratio to override.";
function logDetection(asJson: boolean, message: string): void {
if (asJson) return;
// `matched` is the only branch with its own affirmative phrasing; the
// rest share the fallback hint to keep the user oriented after a miss.
const suffix = message.startsWith("Detected aspect ratio") ? "" : `; ${ASPECT_FALLBACK_HINT}`;
console.log(c.dim(` ${message}${suffix}`));
}
// fallow-ignore-next-line complexity
function summarizeDetection(detection: AspectRatioDetection, entryRelative: string): string {
switch (detection.kind) {
case "matched":
return `Detected aspect ratio: ${detection.aspectRatio} (from ${entryRelative} dims ${detection.width}×${detection.height})`;
case "no-root-div":
return `No <div data-composition-id> found in ${entryRelative}`;
case "no-dims":
return `${entryRelative} root composition has no data-width / data-height`;
case "invalid-dims":
return `${entryRelative} root has invalid dims (${detection.width}×${detection.height})`;
case "no-match":
return `${entryRelative} dims ${detection.width}×${detection.height} (ratio ${detection.ratio.toFixed(2)}) don't match 16:9, 9:16, or 1:1`;
case "read-error":
return `Couldn't read ${entryRelative} for aspect-ratio detection (${detection.error})`;
}
}
function resolveVariablesAndValidateIfLocal(
inline: string | undefined,
filePath: string | undefined,