diff --git a/packages/cli/package.json b/packages/cli/package.json index 91585c306d..15a231f0c4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,12 +35,10 @@ "fontkit": "^2.0.4", "giget": "^3.2.0", "hono": "^4.0.0", - "onnxruntime-node": "^1.20.0", "open": "^10.0.0", "postcss": "^8.5.8", "prettier": "^3.8.1", - "puppeteer-core": "^24.39.1", - "sharp": "^0.34.5" + "puppeteer-core": "^24.39.1" }, "devDependencies": { "@clack/prompts": "^1.1.0", @@ -62,7 +60,9 @@ "vitest": "^3.2.4" }, "optionalDependencies": { - "@google/genai": "^1.50.1" + "@google/genai": "^1.50.1", + "onnxruntime-node": "^1.20.0", + "sharp": "^0.34.5" }, "engines": { "node": ">=22" diff --git a/packages/cli/src/background-removal/inference.test.ts b/packages/cli/src/background-removal/inference.test.ts index 719bcab4d9..c69e309631 100644 --- a/packages/cli/src/background-removal/inference.test.ts +++ b/packages/cli/src/background-removal/inference.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { MEAN, STD, applyMask } from "./inference.js"; // Regression: the u2net_human_seg model was trained with ImageNet @@ -125,3 +125,34 @@ describe("background-removal/inference — applyMask invariants", () => { expect(bg[7]).toBe(0); }); }); + +// onnxruntime-node and sharp are optional native modules; when their platform +// binary can't load, createSession must fail with an actionable install hint +// (and before touching the network / model download), not a raw module error. +describe("background-removal/inference — missing optional native modules", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("createSession throws an actionable error when onnxruntime-node can't load", async () => { + vi.doMock("onnxruntime-node", () => { + throw new Error("Cannot find module 'onnxruntime-node'"); + }); + const { createSession } = await import("./inference.js"); + await expect(createSession()).rejects.toThrow( + /onnxruntime-node.*isn't available[\s\S]*npm i onnxruntime-node/, + ); + vi.doUnmock("onnxruntime-node"); + }); + + it("createSession throws an actionable error when sharp can't load", async () => { + vi.doMock("onnxruntime-node", () => ({ InferenceSession: {}, Tensor: {} })); + vi.doMock("sharp", () => { + throw new Error("Could not load the sharp module"); + }); + const { createSession } = await import("./inference.js"); + await expect(createSession()).rejects.toThrow(/sharp.*isn't available[\s\S]*npm i sharp/); + vi.doUnmock("onnxruntime-node"); + vi.doUnmock("sharp"); + }); +}); diff --git a/packages/cli/src/background-removal/inference.ts b/packages/cli/src/background-removal/inference.ts index 605257fc35..0cfdde0d04 100644 --- a/packages/cli/src/background-removal/inference.ts +++ b/packages/cli/src/background-removal/inference.ts @@ -52,10 +52,26 @@ export interface CreateSessionOptions { onProgress?: (message: string) => void; } +// onnxruntime-node and sharp are optional native modules — their platform +// binaries don't install everywhere. Surface an actionable error instead of a +// raw "Cannot find module" when one can't load. +async function loadNative(name: string, load: () => Promise): Promise { + try { + return await load(); + } catch (err) { + throw new Error( + `remove-background needs the optional native module '${name}', which isn't available ` + + `(${(err as Error).message}). Install it with \`npm i ${name}\`, or reinstall hyperframes with optional dependencies enabled.`, + ); + } +} + export async function createSession(options: CreateSessionOptions = {}): Promise { - const ort = (await import("onnxruntime-node")) as unknown as OrtModule; - const sharpMod = await import("sharp"); - const sharp = sharpMod.default as Sharp; + const ort = (await loadNative( + "onnxruntime-node", + () => import("onnxruntime-node"), + )) as unknown as OrtModule; + const sharp = (await loadNative("sharp", () => import("sharp"))).default as Sharp; const choice = selectProviders(options.device ?? "auto"); const path = await ensureModel(options.model, { onProgress: options.onProgress }); diff --git a/packages/cli/src/capture/contentExtractor.ts b/packages/cli/src/capture/contentExtractor.ts index 2a3c0ba0be..8fdd1681e1 100644 --- a/packages/cli/src/capture/contentExtractor.ts +++ b/packages/cli/src/capture/contentExtractor.ts @@ -11,7 +11,7 @@ import type { Page } from "puppeteer-core"; import { existsSync, readdirSync, statSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import sharp from "sharp"; +import type sharpType from "sharp"; import type { CatalogedAsset } from "./assetCataloger.js"; import type { DesignTokens } from "./types.js"; @@ -247,6 +247,20 @@ export async function captionImagesWithGemini( } if (svgFiles.length > 0) { + // sharp is an optional native module; its platform binary fails to load + // on some installs (omit-optional, musl/glibc, monorepo hoisting, broken + // cache). Load it lazily and degrade to skipping SVG captioning rather + // than crashing the whole capture command on import. + let sharp: typeof sharpType; + try { + sharp = (await import("sharp")).default as typeof sharpType; + } catch (err) { + warnings.push( + `Skipped ${svgFiles.length} SVG caption(s): sharp could not load (${(err as Error).message}). ` + + `Reinstall with optional dependencies enabled (e.g. \`npm i sharp\`) to caption SVG assets.`, + ); + return geminiCaptions; + } progress("design", `Rasterizing + captioning ${svgFiles.length} SVGs via vision API...`); const SVG_BATCH = 20; const SVG_RENDER_SIZE = 256; // px — enough resolution for Gemini to read wordmarks, small enough to keep payload sub-MB diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 99f8f8aba8..76ca106a8a 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -464,13 +464,16 @@ const area = intersectionArea(a.rect, b.rect); if (area <= Math.min(rectArea(a.rect), rectArea(b.rect)) * 0.2) return null; return { + // Warning, not error: must not fail the exit code (ok = errorCount === 0) + // for compositions that intentionally layer text. Re-promote once the + // data-layout-allow-overlap opt-out is widely adopted. code: "content_overlap", - severity: "error", + severity: "warning", time, selector: selectorFor(a.element), containerSelector: selectorFor(b.element), text: textContentFor(a.element), - message: "Two text blocks overlap and render unreadable.", + message: "Two text blocks overlap and may render unreadable.", rect: a.rect, fixHint: "Give each block its own zone, or mark intentional layering with data-layout-allow-overlap.", diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 9208dc9e94..6e3fc00e59 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -31,6 +31,11 @@ var __dirname = __hf_dirname(__filename);`, "puppeteer-core", "puppeteer", "@puppeteer/browsers", + // Native module — its platform binary (@img/sharp--) must be + // resolved from node_modules at runtime, never bundled. Loaded lazily by + // the capture pipeline; runtime resolution comes from the `dependencies` + // entry in package.json. + "sharp", "open", "hono", "hono/*",