From 41979f2bbb9a8b40e00485ca852fbbb87e59e40c Mon Sep 17 00:00:00 2001 From: James Date: Mon, 1 Jun 2026 00:55:27 +0000 Subject: [PATCH] feat(cli): auto-detect aspect_ratio from composition dims when --aspect-ratio is omitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user runs `hyperframes cloud render` without `--aspect-ratio` and the project source is a local directory, parse the entry HTML's root `
` 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). --- .../cli/src/cloud/detectAspectRatio.test.ts | 196 ++++++++++++++++++ packages/cli/src/cloud/detectAspectRatio.ts | 124 +++++++++++ packages/cli/src/commands/cloud/render.ts | 78 ++++++- 3 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/cloud/detectAspectRatio.test.ts create mode 100644 packages/cli/src/cloud/detectAspectRatio.ts diff --git a/packages/cli/src/cloud/detectAspectRatio.test.ts b/packages/cli/src/cloud/detectAspectRatio.test.ts new file mode 100644 index 000000000..bae5eb672 --- /dev/null +++ b/packages/cli/src/cloud/detectAspectRatio.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import { + ASPECT_RATIO_MATCH_TOLERANCE, + detectAspectRatioFromHtmlString, +} from "./detectAspectRatio.js"; + +const ROOT_DIV = (width: string | number, height: string | number, extra = "") => + `
`; + +describe("detectAspectRatioFromHtmlString — matches", () => { + it("matches 16:9 on canonical 1920x1080", () => { + const result = detectAspectRatioFromHtmlString(ROOT_DIV(1920, 1080)); + expect(result.kind).toBe("matched"); + if (result.kind === "matched") { + expect(result.aspectRatio).toBe("16:9"); + expect(result.width).toBe(1920); + expect(result.height).toBe(1080); + } + }); + + it("matches 9:16 on canonical 1080x1920", () => { + const result = detectAspectRatioFromHtmlString(ROOT_DIV(1080, 1920)); + expect(result.kind).toBe("matched"); + if (result.kind === "matched") { + expect(result.aspectRatio).toBe("9:16"); + } + }); + + it("matches 1:1 on canonical 1080x1080", () => { + const result = detectAspectRatioFromHtmlString(ROOT_DIV(1080, 1080)); + expect(result.kind).toBe("matched"); + if (result.kind === "matched") { + expect(result.aspectRatio).toBe("1:1"); + } + }); + + it("matches 16:9 on 1280x720 (smaller resolution, same ratio)", () => { + const result = detectAspectRatioFromHtmlString(ROOT_DIV(1280, 720)); + expect(result.kind).toBe("matched"); + if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9"); + }); + + it("matches 16:9 on 3840x2160 (4K)", () => { + const result = detectAspectRatioFromHtmlString(ROOT_DIV(3840, 2160)); + expect(result.kind).toBe("matched"); + if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9"); + }); + + it("matches within tolerance for 1916x1080 (16:9 with slight pixel slop)", () => { + // 1916/1080 = 1.774 — within ±0.05 of 16/9 = 1.778 + const result = detectAspectRatioFromHtmlString(ROOT_DIV(1916, 1080)); + expect(result.kind).toBe("matched"); + if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9"); + }); +}); + +describe("detectAspectRatioFromHtmlString — non-matches surface as warnings", () => { + it("returns no-match for 4:5 portrait social (864x1080)", () => { + const result = detectAspectRatioFromHtmlString(ROOT_DIV(864, 1080)); + expect(result.kind).toBe("no-match"); + if (result.kind === "no-match") { + expect(result.width).toBe(864); + expect(result.height).toBe(1080); + expect(result.ratio).toBeCloseTo(0.8, 2); + } + }); + + it("returns no-match for 5:4 (1350x1080) — outside 1:1 tolerance band", () => { + const result = detectAspectRatioFromHtmlString(ROOT_DIV(1350, 1080)); + expect(result.kind).toBe("no-match"); + if (result.kind === "no-match") { + expect(result.ratio).toBeCloseTo(1.25, 2); + } + }); + + it("returns no-match for 3:2 ratio (1500x1000) — outside all three bands", () => { + // 1500/1000 = 1.5. 16:9 = 1.778 (Δ ≈ 0.28), 1:1 = 1.0 (Δ = 0.5), + // 9:16 = 0.5625 (Δ ≈ 0.94). All outside ±0.05. Common ratio for + // older photo-style compositions; needs the no-match warning so + // the user opts into a supported ratio explicitly. + const result = detectAspectRatioFromHtmlString(ROOT_DIV(1500, 1000)); + expect(result.kind).toBe("no-match"); + if (result.kind === "no-match") { + expect(result.ratio).toBeCloseTo(1.5, 2); + } + }); + + it("returns no-match for 21:9 ultrawide (2560x1080)", () => { + // 2560/1080 = 2.37. Outside ±0.05 of 16/9 = 1.778. + const result = detectAspectRatioFromHtmlString(ROOT_DIV(2560, 1080)); + expect(result.kind).toBe("no-match"); + }); + + it("4:5 is genuinely outside the 1:1 tolerance band — sanity-check the cutoff", () => { + // 4:5 = 0.8; 1:1 = 1.0; difference = 0.2 — well outside 0.05 tolerance. + // This pins the tolerance choice: if someone tightens or loosens it, + // 4:5 must still NOT match 1:1, otherwise we silently mis-classify + // portrait social as square. + const ratio = 0.8; + const diffFromOne = Math.abs(ratio - 1.0); + expect(diffFromOne).toBeGreaterThan(ASPECT_RATIO_MATCH_TOLERANCE); + }); +}); + +describe("detectAspectRatioFromHtmlString — structural edge cases", () => { + it("returns no-root-div when HTML has no composition root", () => { + const html = "

no composition here

"; + expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-root-div"); + }); + + it("returns no-dims when root div is missing data-width", () => { + const html = `
`; + expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims"); + }); + + it("returns no-dims when root div is missing data-height", () => { + const html = `
`; + expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims"); + }); + + it("returns invalid-dims when width/height are zero", () => { + const result = detectAspectRatioFromHtmlString(ROOT_DIV(0, 1080)); + expect(result.kind).toBe("invalid-dims"); + if (result.kind === "invalid-dims") expect(result.width).toBe(0); + }); + + it("returns no-dims when data-width includes a sign character", () => { + // The attribute extractor only captures unsigned numbers — a negative + // value (`-1920`) doesn't match the regex at all and the field reads + // as "missing". That's the desired outcome; we don't want to surface + // negative dims as a separate failure mode the user has to interpret. + const result = detectAspectRatioFromHtmlString(ROOT_DIV(-1920, 1080)); + expect(result.kind).toBe("no-dims"); + }); + + it("handles unquoted attribute values", () => { + const html = `
`; + const result = detectAspectRatioFromHtmlString(html); + expect(result.kind).toBe("matched"); + if (result.kind === "matched") expect(result.aspectRatio).toBe("16:9"); + }); + + it("handles single-quoted attribute values", () => { + const html = `
`; + expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched"); + }); + + it("ignores attributes on the wrong tag", () => { + // A non-composition div has the dims; the actual root has neither. The + // extractor must NOT swap attributes across tags. + const html = ` +
+
+ `; + expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims"); + }); + + it("uses the FIRST composition root encountered (the page root)", () => { + // Sub-compositions also have data-composition-id; the regex finds the + // first match, which by HF convention is the page-level root. + const html = ` +
+
+
+ `; + const result = detectAspectRatioFromHtmlString(html); + expect(result.kind).toBe("matched"); + if (result.kind === "matched") { + expect(result.aspectRatio).toBe("16:9"); + expect(result.width).toBe(1920); + expect(result.height).toBe(1080); + } + }); + + it("handles attributes in arbitrary order", () => { + const html = `
`; + const result = detectAspectRatioFromHtmlString(html); + expect(result.kind).toBe("matched"); + if (result.kind === "matched") expect(result.aspectRatio).toBe("9:16"); + }); + + it("handles whitespace + newlines inside the opening tag", () => { + const html = `
`; + expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched"); + }); + + it("handles a self-closing-style tag (rare but valid in MDX/JSX-flavored input)", () => { + const html = `
`; + expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched"); + }); +}); diff --git a/packages/cli/src/cloud/detectAspectRatio.ts b/packages/cli/src/cloud/detectAspectRatio.ts new file mode 100644 index 000000000..1de9fe445 --- /dev/null +++ b/packages/cli/src/cloud/detectAspectRatio.ts @@ -0,0 +1,124 @@ +/** + * Auto-detect a HyperFrames composition's aspect ratio from the entry HTML's + * root `
` `data-width` / `data-height` attributes. + * + * The cloud-render CLI uses this when the user hasn't passed `--aspect-ratio` + * explicitly AND the project source is a local directory (asset-id and url + * paths can't be inspected client-side). The result is passed through to the + * `/v3/hyperframes/renders` request body, so the rendered output preserves + * the composition's intended ratio without the user having to remember the + * flag. + * + * Detection only matches the three values the API currently supports: + * `16:9`, `9:16`, `1:1`. Anything else (e.g. 4:5, 5:4, or an unusual custom + * ratio) returns a `"no-match"` result with the computed ratio for the + * caller to surface to the user. `auto`-style fallbacks (server-side, etc.) + * are out of scope here. + * + * Parsing approach: a narrow regex over the HTML. The root composition div is + * a well-defined pattern (`data-composition-id` always appears on it) and we + * only need two attributes off the same tag. Pulling in `jsdom` or a full DOM + * parser is heavier than the problem warrants. + */ + +import { readFileSync } from "node:fs"; + +export type SupportedAspectRatio = "16:9" | "9:16" | "1:1"; + +export type AspectRatioDetection = + | { + kind: "matched"; + aspectRatio: SupportedAspectRatio; + width: number; + height: number; + } + | { kind: "no-root-div" } + | { kind: "no-dims" } + | { kind: "invalid-dims"; width: number; height: number } + | { kind: "no-match"; width: number; height: number; ratio: number } + | { kind: "read-error"; error: string }; + +// Absolute tolerance on the computed ratio. Wide enough to absorb +// floating-point sloppiness on canonical ratios (e.g. 1920×1080 = 1.7778); +// tight enough to keep 4:5 (0.8) and 5:4 (1.25) outside the bands so they +// fall through to the "no-match" warning instead of getting silently +// mis-classified as 1:1 or 16:9. +const RATIO_TOLERANCE = 0.05; + +const SUPPORTED_RATIOS: Array<{ value: SupportedAspectRatio; ratio: number }> = [ + { value: "16:9", ratio: 16 / 9 }, + { value: "9:16", ratio: 9 / 16 }, + { value: "1:1", ratio: 1 }, +]; + +// First `
` opening tag in the file. +// Quote style is intentionally permissive — single, double, or unquoted all +// match. Case-insensitive to handle `
` or `Data-Composition-Id` mid-edit. +const ROOT_COMPOSITION_DIV_RE = + /]*?\bdata-composition-id\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)[^>]*>/i; + +// `data-width` / `data-height` attribute extractors. Accept integer or float +// values, quoted or unquoted. The `\d` class restricts to ASCII digits — no +// locale comma surprises. +const DATA_WIDTH_RE = + /\bdata-width\s*=\s*(?:"(\d+(?:\.\d+)?)"|'(\d+(?:\.\d+)?)'|(\d+(?:\.\d+)?))(?=\s|>|\/)/i; +const DATA_HEIGHT_RE = + /\bdata-height\s*=\s*(?:"(\d+(?:\.\d+)?)"|'(\d+(?:\.\d+)?)'|(\d+(?:\.\d+)?))(?=\s|>|\/)/i; + +function extractAttributeNumber(tag: string, re: RegExp): number | null { + const match = tag.match(re); + if (!match) return null; + // First capture group that matched (quoted-double | quoted-single | unquoted). + const raw = match[1] ?? match[2] ?? match[3]; + if (raw === undefined) return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +/** + * Parse the HTML at `entryHtmlPath` and detect which supported aspect ratio + * the composition's root div is authored at. + * + * Pure function except for `readFileSync` — no logging, no `process.exit`. + * The caller decides how to surface each result kind to the user. + */ +export function detectAspectRatioFromHtml(entryHtmlPath: string): AspectRatioDetection { + let html: string; + try { + html = readFileSync(entryHtmlPath, "utf-8"); + } catch (err) { + return { kind: "read-error", error: err instanceof Error ? err.message : String(err) }; + } + return detectAspectRatioFromHtmlString(html); +} + +/** + * Same as `detectAspectRatioFromHtml`, but takes the HTML as a string instead + * of a file path. Exposed for tests + composition-string callers. + */ +export function detectAspectRatioFromHtmlString(html: string): AspectRatioDetection { + const tagMatch = html.match(ROOT_COMPOSITION_DIV_RE); + if (!tagMatch) return { kind: "no-root-div" }; + + const openTag = tagMatch[0]; + const width = extractAttributeNumber(openTag, DATA_WIDTH_RE); + const height = extractAttributeNumber(openTag, DATA_HEIGHT_RE); + + if (width === null || height === null) return { kind: "no-dims" }; + if (width <= 0 || height <= 0) return { kind: "invalid-dims", width, height }; + + const ratio = width / height; + for (const candidate of SUPPORTED_RATIOS) { + if (Math.abs(ratio - candidate.ratio) <= RATIO_TOLERANCE) { + return { kind: "matched", aspectRatio: candidate.value, width, height }; + } + } + return { kind: "no-match", width, height, ratio }; +} + +/** + * The tolerance used when matching the computed ratio to a supported value. + * Exposed for tests + caller introspection (e.g. warning messages that want + * to mention the bounds). + */ +export const ASPECT_RATIO_MATCH_TOLERANCE = RATIO_TOLERANCE; diff --git a/packages/cli/src/commands/cloud/render.ts b/packages/cli/src/commands/cloud/render.ts index 925cf93c0..e02979acd 100644 --- a/packages/cli/src/commands/cloud/render.ts +++ b/packages/cli/src/commands/cloud/render.ts @@ -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
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,