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
8 changes: 4 additions & 4 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
33 changes: 32 additions & 1 deletion packages/cli/src/background-removal/inference.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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");
});
});
22 changes: 19 additions & 3 deletions packages/cli/src/background-removal/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(name: string, load: () => Promise<T>): Promise<T> {
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<Session> {
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 });
Expand Down
16 changes: 15 additions & 1 deletion packages/cli/src/capture/contentExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ var __dirname = __hf_dirname(__filename);`,
"puppeteer-core",
"puppeteer",
"@puppeteer/browsers",
// Native module — its platform binary (@img/sharp-<os>-<arch>) 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/*",
Expand Down
Loading