Files
hyperframes/packages/cli/src/cloud/detectAspectRatio.ts
T
Miguel Ángel a5c2636e8c fix(cli): never print "[object Object]" from validate/inspect errors (#1810)
* fix(cli): use normalizeErrorMessage so validate/inspect never print "[object Object]"

The validate and inspect (layout) commands formatted thrown values with
`err instanceof Error ? err.message : String(err)`. When a browser/CDP/
Puppeteer protocol error or a structured page error reaches the formatter
as a plain object without a string `message`, `String(obj)` yields the
useless literal "[object Object]", hiding the real cause.

Route those paths through the existing shared `normalizeErrorMessage`
helper, which returns an Error's message, a string as-is, an object's
`.message` when present, or a compact JSON serialization otherwise (with
a key-list and String fallback for circular/opaque objects). Also fold
the duplicated local `errorMessage` helpers in batchRender and preview
into the same shared helper.

Covered by added assertions in errorMessage.test.ts for the no-message
object and Puppeteer-style protocol-error object cases.

* fix(cli): route remaining browser/process error sites through normalizeErrorMessage

The validate/inspect fix routed only those two commands through the shared
normalizeErrorMessage helper. The same err instanceof Error ? err.message :
String(err) pattern survived in the other commands that drive a headless
browser or an external process (ffmpeg, Docker, CDP) or surface a network
API error, so a thrown structured object without a string message would
still render as the useless literal [object Object].

Route those sites through the shared helper:
  snapshot.ts (the closest sibling to validate/inspect, same bug class),
  render.ts (Chrome launch + Docker build), capture/index.ts and
  commands/capture.ts (page-driven extraction), auth/browser.ts,
  browser/manager.ts (Puppeteer browser resolution), and the cloud/lambda
  paths (cloud/render.ts, cloudrun.ts, lambda/render-batch.ts,
  lambda/policies.ts, cloud/detectAspectRatio.ts) that surface API/network
  error objects.

Only the message-deriving expression changes; control flow and error
propagation are untouched. capture/index.ts keeps appending the stack for
real Errors and only routes the non-Error branch. Adds a helper test for a
structured CDP-style error object (code + nested data, no message).
2026-06-30 10:47:54 -07:00

126 lines
5.1 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.
/**
* 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";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
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: normalizeErrorMessage(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;