mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
Aimed at `npx hyperframes` users (standalone and inside monorepos), where the
native modules `sharp` and `onnxruntime-node` can't install or load.
## Native modules are now optional, and never abort the CLI
`sharp` and `onnxruntime-node` are native modules: their platform binaries ship
as optional sub-dependencies that can fail to land on end-user installs
(--omit=optional, musl/glibc, monorepo hoisting, cross-platform lockfiles,
broken npx cache). Both powered only optional commands, yet both were wired as
hard `dependencies`, so on any platform where a binary can't install the whole
CLI failed to install. Moved both to `optionalDependencies` (alongside
@google/genai) so the core CLI always installs; the native-accelerated paths
light up only when present.
Runtime handling so a missing/unloadable binary degrades instead of crashing:
- `capture` (`contentExtractor.ts`): sharp was a static top-level
`import sharp from "sharp"`, so a load failure threw on module import —
before the inner try/catch — aborting the whole command. Now a guarded lazy
`await import("sharp")` that skips SVG captioning with an actionable warning.
Marked `external` in tsup so esbuild never bundles the native module.
- `remove-background` (`inference.ts`): both `onnxruntime-node` and `sharp`
are loaded here and genuinely required. The dynamic imports are now guarded
to throw an actionable "install / reinstall with optional deps" error
(surfaced cleanly by the command's existing try/catch) instead of a raw
"Cannot find module". New tests assert createSession rejects with that
guidance — before touching the model download — when either module is
unavailable.
`contactSheet.ts` also uses sharp but is already behind a dynamic-import
boundary wrapped in try/catch, so it was never a hard-fatal path.
## inspect: content-overlap as a warning, not a blocking error
The `content_overlap` layout-audit check shipped as `severity: "error"`, and
the audit exits non-zero when `errorCount > 0`, so `inspect` failed for
compositions that intentionally layer text. Downgraded to `severity: "warning"`
so it still reports (and prints the `data-layout-allow-overlap` opt-out hint)
without breaking exit codes. Reversible.
159 lines
5.8 KiB
TypeScript
159 lines
5.8 KiB
TypeScript
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
|
|
// normalization. Drifting away from these exact values changes the input
|
|
// tensor at every pixel and shifts the predicted alpha mask noticeably
|
|
// (Miguel reproduced 8,317 pixel changes with delta up to 78/255 when std
|
|
// was set to (1, 1, 1)). Reference:
|
|
// https://github.com/danielgatis/rembg/blob/main/rembg/sessions/u2net_human_seg.py#L33
|
|
describe("background-removal/inference — rembg u2net_human_seg parity", () => {
|
|
it("MEAN matches U2netHumanSegSession reference", () => {
|
|
expect(MEAN).toEqual([0.485, 0.456, 0.406]);
|
|
});
|
|
|
|
it("STD matches U2netHumanSegSession reference (ImageNet, not the base u2net's (1,1,1))", () => {
|
|
expect(STD).toEqual([0.229, 0.224, 0.225]);
|
|
});
|
|
});
|
|
|
|
// These tests pin the contract that `--background-output` is built on:
|
|
// fg.alpha + bg.alpha === 255 per pixel, and the RGB plane is byte-identical
|
|
// between fg and bg. A future change to the postprocess loop (different mask
|
|
// threshold, premultiplied alpha, gamma-corrected compositing) that breaks
|
|
// either invariant should fail here loudly.
|
|
describe("background-removal/inference — applyMask invariants", () => {
|
|
function makeRgb(pixels: number): Buffer {
|
|
// Deterministic but non-trivial RGB so byte equality is meaningful.
|
|
const buf = Buffer.allocUnsafe(pixels * 3);
|
|
for (let i = 0; i < pixels; i++) {
|
|
buf[i * 3] = (i * 7) & 0xff;
|
|
buf[i * 3 + 1] = (i * 13 + 31) & 0xff;
|
|
buf[i * 3 + 2] = (i * 19 + 61) & 0xff;
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
function makeMask(pixels: number): Buffer {
|
|
// Hit the saturation endpoints (0, 255) and a few mid-tone values so the
|
|
// 255-m inversion is exercised across the full byte range.
|
|
const buf = Buffer.allocUnsafe(pixels);
|
|
for (let i = 0; i < pixels; i++) buf[i] = (i * 37) & 0xff;
|
|
return buf;
|
|
}
|
|
|
|
it("dual-output: fg.alpha + bg.alpha === 255 for every pixel", () => {
|
|
const pixels = 64;
|
|
const rgb = makeRgb(pixels);
|
|
const mask = makeMask(pixels);
|
|
const fg = Buffer.allocUnsafe(pixels * 4);
|
|
const bg = Buffer.allocUnsafe(pixels * 4);
|
|
|
|
const result = applyMask(rgb, mask, fg, bg, pixels);
|
|
|
|
expect(result.fg).toBe(fg);
|
|
expect(result.bg).toBe(bg);
|
|
for (let i = 0; i < pixels; i++) {
|
|
const sum = fg[i * 4 + 3]! + bg[i * 4 + 3]!;
|
|
expect(sum).toBe(255);
|
|
}
|
|
});
|
|
|
|
it("dual-output: RGB triples are byte-identical between fg and bg", () => {
|
|
const pixels = 64;
|
|
const rgb = makeRgb(pixels);
|
|
const mask = makeMask(pixels);
|
|
const fg = Buffer.allocUnsafe(pixels * 4);
|
|
const bg = Buffer.allocUnsafe(pixels * 4);
|
|
|
|
applyMask(rgb, mask, fg, bg, pixels);
|
|
|
|
for (let i = 0; i < pixels; i++) {
|
|
expect(fg[i * 4]).toBe(bg[i * 4]);
|
|
expect(fg[i * 4 + 1]).toBe(bg[i * 4 + 1]);
|
|
expect(fg[i * 4 + 2]).toBe(bg[i * 4 + 2]);
|
|
// And both match the source.
|
|
expect(fg[i * 4]).toBe(rgb[i * 3]);
|
|
expect(fg[i * 4 + 1]).toBe(rgb[i * 3 + 1]);
|
|
expect(fg[i * 4 + 2]).toBe(rgb[i * 3 + 2]);
|
|
}
|
|
});
|
|
|
|
it("dual-output: fg.alpha equals the input mask", () => {
|
|
const pixels = 32;
|
|
const rgb = makeRgb(pixels);
|
|
const mask = makeMask(pixels);
|
|
const fg = Buffer.allocUnsafe(pixels * 4);
|
|
const bg = Buffer.allocUnsafe(pixels * 4);
|
|
|
|
applyMask(rgb, mask, fg, bg, pixels);
|
|
|
|
for (let i = 0; i < pixels; i++) {
|
|
expect(fg[i * 4 + 3]).toBe(mask[i]);
|
|
}
|
|
});
|
|
|
|
it("single-output: bg=null returns bg=null and writes only fg", () => {
|
|
const pixels = 32;
|
|
const rgb = makeRgb(pixels);
|
|
const mask = makeMask(pixels);
|
|
const fg = Buffer.allocUnsafe(pixels * 4);
|
|
|
|
const result = applyMask(rgb, mask, fg, null, pixels);
|
|
|
|
expect(result.bg).toBeNull();
|
|
expect(result.fg).toBe(fg);
|
|
for (let i = 0; i < pixels; i++) {
|
|
expect(fg[i * 4]).toBe(rgb[i * 3]);
|
|
expect(fg[i * 4 + 3]).toBe(mask[i]);
|
|
}
|
|
});
|
|
|
|
it("saturates correctly at mask=0 and mask=255", () => {
|
|
// mask=0 → fg.alpha=0 (transparent subject), bg.alpha=255 (fully opaque plate)
|
|
// mask=255 → fg.alpha=255 (fully opaque subject), bg.alpha=0 (transparent plate)
|
|
const rgb = Buffer.from([10, 20, 30, 40, 50, 60]);
|
|
const mask = Buffer.from([0, 255]);
|
|
const fg = Buffer.allocUnsafe(8);
|
|
const bg = Buffer.allocUnsafe(8);
|
|
|
|
applyMask(rgb, mask, fg, bg, 2);
|
|
|
|
expect(fg[3]).toBe(0);
|
|
expect(bg[3]).toBe(255);
|
|
expect(fg[7]).toBe(255);
|
|
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");
|
|
});
|
|
});
|