Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions packages/cli/src/cloud/detectAspectRatio.test.ts
Original file line number Diff line number Diff line change
@@ -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 = "") =>
`<div data-composition-id="root" data-width="${width}" data-height="${height}"${extra ? " " + extra : ""}></div>`;

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 = "<html><body><h1>no composition here</h1></body></html>";
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-root-div");
});

it("returns no-dims when root div is missing data-width", () => {
const html = `<div data-composition-id="root" data-height="1080"></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("no-dims");
});

it("returns no-dims when root div is missing data-height", () => {
const html = `<div data-composition-id="root" data-width="1920"></div>`;
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 = `<div data-composition-id=root data-width=1920 data-height=1080></div>`;
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 = `<div data-composition-id='root' data-width='1920' data-height='1080'></div>`;
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 = `
<div class="header" data-width="100" data-height="100"></div>
<div data-composition-id="root"></div>
`;
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 = `
<div data-composition-id="root" data-width="1920" data-height="1080">
<div data-composition-id="sub" data-width="1080" data-height="1080"></div>
</div>
`;
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 = `<div data-width="1080" id="root" data-height="1920" data-composition-id="root" class="bg"></div>`;
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 = `<div
data-composition-id="root"
data-width="1080"
data-height="1080"
></div>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched");
});

it("handles a self-closing-style tag (rare but valid in MDX/JSX-flavored input)", () => {
const html = `<div data-composition-id="root" data-width="1920" data-height="1080"/>`;
expect(detectAspectRatioFromHtmlString(html).kind).toBe("matched");
});
});
124 changes: 124 additions & 0 deletions packages/cli/src/cloud/detectAspectRatio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Auto-detect a HyperFrames composition's aspect ratio from the entry HTML's
* root `<div data-composition-id ...>` `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 `<div ... data-composition-id="..." ...>` opening tag in the file.
// Quote style is intentionally permissive — single, double, or unquoted all
// match. Case-insensitive to handle `<DIV>` or `Data-Composition-Id` mid-edit.
const ROOT_COMPOSITION_DIV_RE =
/<div\b[^>]*?\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;
Loading
Loading