feat(#21): Gate B green/black sheet mask (TV-1, TV-2)

Pure client modules for TECH-SPEC sheet detect, chroma-key / black
threshold with border flood-fill, four knobs (live re-run), and mask
confirm hooks. Synthetic fixtures. U2Net never invoked.

TDD: red evidence in test-results/tv1-tv2-red.txt, then minimum impl.

Results:
TV-1 | pass | test-results/tv1-tv2-green.txt
TV-2 | pass | test-results/tv1-tv2-green.txt
This commit is contained in:
2026-09-07 05:53:43 +00:00
parent f02b53dfa2
commit 3c2f8871a8
12 changed files with 2082 additions and 0 deletions
+1362
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "slabuploader-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"typescript": "^5.6.3",
"vitest": "^2.1.4"
}
}
@@ -0,0 +1,116 @@
import type { RgbaImage } from "../../lib/pipeline/types.js";
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
const ss = s / 100;
const vv = v / 100;
const c = vv * ss;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = vv - c;
let rp = 0;
let gp = 0;
let bp = 0;
if (h < 60) {
rp = c;
gp = x;
} else if (h < 120) {
rp = x;
gp = c;
} else if (h < 180) {
gp = c;
bp = x;
} else if (h < 240) {
gp = x;
bp = c;
} else if (h < 300) {
rp = x;
bp = c;
} else {
rp = c;
bp = x;
}
return [
Math.round((rp + m) * 255),
Math.round((gp + m) * 255),
Math.round((bp + m) * 255),
];
}
function fillRect(
img: RgbaImage,
x0: number,
y0: number,
x1: number,
y1: number,
rgb: [number, number, number],
): void {
const [r, g, b] = rgb;
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const i = (y * img.width + x) * 4;
img.data[i] = r;
img.data[i + 1] = g;
img.data[i + 2] = b;
img.data[i + 3] = 255;
}
}
}
export function blankImage(width: number, height: number): RgbaImage {
return { width, height, data: new Uint8ClampedArray(width * height * 4) };
}
/**
* TV-1 fixture: green sheet border, brown wood slab, interior green-ish patch
* that does not touch the image border.
*/
export function greenSheetFixture(size = 64): {
image: RgbaImage;
interiorGreen: { x: number; y: number };
woodSample: { x: number; y: number };
borderSample: { x: number; y: number };
} {
const image = blankImage(size, size);
const green = hsvToRgb(60, 80, 80);
const wood = hsvToRgb(30, 55, 55);
const interiorGreen = hsvToRgb(55, 70, 70);
fillRect(image, 0, 0, size, size, green);
// Wood slab inset so it does not touch border.
fillRect(image, 12, 12, size - 12, size - 12, wood);
// Interior green-ish patch fully inside wood (no border contact).
fillRect(image, 28, 28, 36, 36, interiorGreen);
return {
image,
interiorGreen: { x: 32, y: 32 },
woodSample: { x: 20, y: 20 },
borderSample: { x: 2, y: 2 },
};
}
/**
* TV-2 fixture: black sheet border, mid-tone wood, dark interior wood patch
* that does not touch the border.
*/
export function blackSheetFixture(size = 64): {
image: RgbaImage;
darkInterior: { x: number; y: number };
woodSample: { x: number; y: number };
borderSample: { x: number; y: number };
} {
const image = blankImage(size, size);
const black: [number, number, number] = [8, 8, 8];
const wood: [number, number, number] = [140, 110, 80];
const darkWood: [number, number, number] = [18, 14, 10];
fillRect(image, 0, 0, size, size, black);
fillRect(image, 12, 12, size - 12, size - 12, wood);
fillRect(image, 28, 28, 36, 36, darkWood);
return {
image,
darkInterior: { x: 32, y: 32 },
woodSample: { x: 20, y: 20 },
borderSample: { x: 2, y: 2 },
};
}
export function maskAt(mask: Uint8Array, width: number, x: number, y: number): number {
return mask[y * width + x]!;
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
confirmMask,
createMaskSession,
defaultKnobs,
removeSheetBackground,
reRunMask,
requestRetake,
} from "../lib/pipeline/mask.js";
import {
blackSheetFixture,
greenSheetFixture,
maskAt,
} from "./fixtures/synthetic.js";
describe("TV-1 green-sheet chroma-key", () => {
it("keeps interior green-ish patch that does not touch the border", () => {
const { image, interiorGreen, woodSample, borderSample } = greenSheetFixture();
const knobs = { ...defaultKnobs(), sheet: "green" as const };
const result = removeSheetBackground(image, knobs);
expect(result.u2netInvoked).toBe(false);
expect(result.sheetUsed).toBe("green");
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
expect(maskAt(result.mask, result.width, woodSample.x, woodSample.y)).toBeGreaterThan(0);
expect(
maskAt(result.mask, result.width, interiorGreen.x, interiorGreen.y),
).toBeGreaterThan(0);
});
it("re-runs live when knobs change and still skips U2Net", () => {
const { image } = greenSheetFixture();
const first = removeSheetBackground(image, { ...defaultKnobs(), sheet: "green" });
const second = reRunMask(image, {
...defaultKnobs(),
sheet: "green",
sensitivity: 0.7,
edgeOffset: 1,
feather: 1,
});
expect(first.u2netInvoked).toBe(false);
expect(second.u2netInvoked).toBe(false);
expect(second.knobs.sensitivity).toBe(0.7);
});
});
describe("TV-2 black-sheet threshold + border flood-fill", () => {
it("keeps dark interior wood that does not touch the border", () => {
const { image, darkInterior, woodSample, borderSample } = blackSheetFixture();
const knobs = { ...defaultKnobs(), sheet: "black" as const };
const result = removeSheetBackground(image, knobs);
expect(result.u2netInvoked).toBe(false);
expect(result.sheetUsed).toBe("black");
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
expect(maskAt(result.mask, result.width, woodSample.x, woodSample.y)).toBeGreaterThan(0);
expect(
maskAt(result.mask, result.width, darkInterior.x, darkInterior.y),
).toBeGreaterThan(0);
});
it("flood-fill removes only border-connected dark pixels", () => {
const { image, darkInterior, borderSample } = blackSheetFixture();
const result = removeSheetBackground(image, { ...defaultKnobs(), sheet: "black" });
// Border sheet gone; isolated dark island kept.
expect(maskAt(result.mask, result.width, borderSample.x, borderSample.y)).toBe(0);
expect(
maskAt(result.mask, result.width, darkInterior.x, darkInterior.y),
).toBeGreaterThan(0);
});
});
describe("mask confirm hooks (pure)", () => {
it("starts pending, confirm and retake transition without DOM", () => {
const { image } = greenSheetFixture();
const result = removeSheetBackground(image, { ...defaultKnobs(), sheet: "green" });
const session = createMaskSession(result);
expect(session.state).toBe("pending");
expect(confirmMask(session).state).toBe("confirmed");
expect(requestRetake(session).state).toBe("retake");
});
});
+21
View File
@@ -0,0 +1,21 @@
/** TECH-SPEC section 7 factory and code constants for BG removal. */
export const FILL_TARGET = 0.8;
export const ASPECT_W = 3;
export const ASPECT_H = 4;
export const OUTPUT_PX_MIN = 1600;
export const WIDTH_SAMPLE_IN = 6;
export const INCH_STEP = 0.125;
export const AREA_DECIMALS = 2;
export const BDFT_DECIMALS = 2;
export const PRICE_DECIMALS = 2;
export const SHEET_DEFAULT = "auto" as const;
export const GREEN_HUE_LO = 35;
export const GREEN_HUE_HI = 85;
export const GREEN_SAT_MIN = 40;
export const GREEN_VAL_MIN = 40;
export const BLACK_THRESH = 30;
export const SENSITIVITY_DEFAULT = 0.5;
export const EDGE_OFFSET_DEFAULT = 0;
export const FEATHER_DEFAULT = 0;
+3
View File
@@ -0,0 +1,3 @@
export * from "./constants.js";
export * from "./types.js";
export * from "./mask.js";
+326
View File
@@ -0,0 +1,326 @@
import type {
KnobSettings,
MaskBuffer,
MaskResult,
MaskSession,
RgbaImage,
ResolvedSheet,
} from "./types.js";
import {
BLACK_THRESH,
EDGE_OFFSET_DEFAULT,
FEATHER_DEFAULT,
GREEN_HUE_HI,
GREEN_HUE_LO,
GREEN_SAT_MIN,
GREEN_VAL_MIN,
SENSITIVITY_DEFAULT,
SHEET_DEFAULT,
} from "./constants.js";
export function defaultKnobs(): KnobSettings {
return {
sheet: SHEET_DEFAULT,
sensitivity: SENSITIVITY_DEFAULT,
edgeOffset: EDGE_OFFSET_DEFAULT,
feather: FEATHER_DEFAULT,
};
}
function rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === rn) h = ((gn - bn) / d) % 6;
else if (max === gn) h = (bn - rn) / d + 2;
else h = (rn - gn) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
const s = max === 0 ? 0 : (d / max) * 100;
const v = max * 100;
return { h, s, v };
}
function pixelRgb(image: RgbaImage, x: number, y: number): [number, number, number] {
const i = (y * image.width + x) * 4;
return [image.data[i]!, image.data[i + 1]!, image.data[i + 2]!];
}
/** TECH-SPEC section 3.1 auto-detect from 2-5% border strips. */
export function detectSheet(image: RgbaImage): ResolvedSheet | "ambiguous" {
const strip = Math.max(1, Math.floor(Math.min(image.width, image.height) * 0.03));
let hSum = 0;
let sSum = 0;
let vSum = 0;
let n = 0;
const sample = (x: number, y: number) => {
const [r, g, b] = pixelRgb(image, x, y);
const hsv = rgbToHsv(r, g, b);
hSum += hsv.h;
sSum += hsv.s;
vSum += hsv.v;
n += 1;
};
for (let x = 0; x < image.width; x++) {
for (let t = 0; t < strip; t++) {
sample(x, t);
sample(x, image.height - 1 - t);
}
}
for (let y = strip; y < image.height - strip; y++) {
for (let t = 0; t < strip; t++) {
sample(t, y);
sample(image.width - 1 - t, y);
}
}
if (n === 0) return "ambiguous";
const h = hSum / n;
const s = sSum / n;
const v = vSum / n;
if (h >= GREEN_HUE_LO && h <= GREEN_HUE_HI && s >= GREEN_SAT_MIN) return "green";
if (v <= BLACK_THRESH && s <= 20) return "black";
return "ambiguous";
}
function resolveSheet(image: RgbaImage, knobs: KnobSettings): ResolvedSheet {
if (knobs.sheet === "green" || knobs.sheet === "black") return knobs.sheet;
const detected = detectSheet(image);
if (detected === "ambiguous") return "green";
return detected;
}
function greenSheetCandidate(
r: number,
g: number,
b: number,
sensitivity: number,
): boolean {
const { h, s, v } = rgbToHsv(r, g, b);
const widen = (sensitivity - 0.5) * 20;
const hueLo = GREEN_HUE_LO - widen;
const hueHi = GREEN_HUE_HI + widen;
const satMin = Math.max(5, GREEN_SAT_MIN - widen);
const valMin = Math.max(5, GREEN_VAL_MIN - widen);
return h >= hueLo && h <= hueHi && s >= satMin && v >= valMin;
}
function blackSheetCandidate(r: number, g: number, b: number, sensitivity: number): boolean {
const { s, v } = rgbToHsv(r, g, b);
const loosen = (sensitivity - 0.5) * 20;
const thresh = BLACK_THRESH + loosen;
return v <= thresh && s <= 20 + Math.max(0, loosen);
}
function buildSheetCandidates(
image: RgbaImage,
sheet: ResolvedSheet,
sensitivity: number,
): Uint8Array {
const out = new Uint8Array(image.width * image.height);
for (let y = 0; y < image.height; y++) {
for (let x = 0; x < image.width; x++) {
const [r, g, b] = pixelRgb(image, x, y);
const isSheet =
sheet === "green"
? greenSheetCandidate(r, g, b, sensitivity)
: blackSheetCandidate(r, g, b, sensitivity);
out[y * image.width + x] = isSheet ? 1 : 0;
}
}
return out;
}
/** Flood-fill sheet candidates reachable from the image border. */
function borderConnectedSheet(candidates: Uint8Array, width: number, height: number): Uint8Array {
const removed = new Uint8Array(width * height);
const stack: number[] = [];
const push = (x: number, y: number) => {
const i = y * width + x;
if (candidates[i] !== 1 || removed[i] === 1) return;
removed[i] = 1;
stack.push(i);
};
for (let x = 0; x < width; x++) {
push(x, 0);
push(x, height - 1);
}
for (let y = 0; y < height; y++) {
push(0, y);
push(width - 1, y);
}
while (stack.length) {
const i = stack.pop()!;
const x = i % width;
const y = (i / width) | 0;
if (x > 0) push(x - 1, y);
if (x + 1 < width) push(x + 1, y);
if (y > 0) push(x, y - 1);
if (y + 1 < height) push(x, y + 1);
}
return removed;
}
function morphClose(mask: MaskBuffer, width: number, height: number): MaskBuffer {
const dilate = new Uint8Array(mask.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let max = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const xx = x + dx;
const yy = y + dy;
if (xx < 0 || yy < 0 || xx >= width || yy >= height) continue;
const v = mask[yy * width + xx]!;
if (v > max) max = v;
}
}
dilate[y * width + x] = max;
}
}
const erode = new Uint8Array(mask.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let min = 255;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const xx = x + dx;
const yy = y + dy;
if (xx < 0 || yy < 0 || xx >= width || yy >= height) {
min = 0;
continue;
}
const v = dilate[yy * width + xx]!;
if (v < min) min = v;
}
}
erode[y * width + x] = min;
}
}
return erode;
}
function applyEdgeOffset(
mask: MaskBuffer,
width: number,
height: number,
offset: number,
): MaskBuffer {
if (offset === 0) return mask;
const steps = Math.abs(Math.round(offset));
let current = mask;
for (let s = 0; s < steps; s++) {
const next = new Uint8Array(current.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let agg = offset > 0 ? 255 : 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const xx = x + dx;
const yy = y + dy;
if (xx < 0 || yy < 0 || xx >= width || yy >= height) {
if (offset > 0) agg = 0;
continue;
}
const v = current[yy * width + xx]!;
if (offset > 0) {
if (v < agg) agg = v;
} else if (v > agg) {
agg = v;
}
}
}
next[y * width + x] = agg;
}
}
current = next;
}
return current;
}
function applyFeather(
mask: MaskBuffer,
width: number,
height: number,
feather: number,
): MaskBuffer {
const radius = Math.max(0, Math.round(feather));
if (radius === 0) return mask;
const out = new Uint8Array(mask.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = y * width + x;
if (mask[i]! === 0) {
out[i] = 0;
continue;
}
let minDist = radius + 1;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const xx = x + dx;
const yy = y + dy;
if (xx < 0 || yy < 0 || xx >= width || yy >= height || mask[yy * width + xx]! === 0) {
const d = Math.hypot(dx, dy);
if (d < minDist) minDist = d;
}
}
}
if (minDist > radius) out[i] = 255;
else out[i] = Math.round(255 * (minDist / (radius + 1)));
}
}
return out;
}
export function removeSheetBackground(
image: RgbaImage,
knobs: KnobSettings = defaultKnobs(),
): MaskResult {
const sheetUsed = resolveSheet(image, knobs);
const candidates = buildSheetCandidates(image, sheetUsed, knobs.sensitivity);
const removed = borderConnectedSheet(candidates, image.width, image.height);
let mask: MaskBuffer = new Uint8Array(image.width * image.height);
for (let i = 0; i < mask.length; i++) {
mask[i] = removed[i] === 1 ? 0 : 255;
}
mask = morphClose(mask, image.width, image.height);
mask = applyEdgeOffset(mask, image.width, image.height, knobs.edgeOffset);
mask = applyFeather(mask, image.width, image.height, knobs.feather);
return {
mask,
width: image.width,
height: image.height,
sheetUsed,
u2netInvoked: false,
knobs: { ...knobs },
};
}
export function reRunMask(image: RgbaImage, knobs: KnobSettings): MaskResult {
return removeSheetBackground(image, knobs);
}
export function createMaskSession(result: MaskResult): MaskSession {
return { result, state: "pending" };
}
export function confirmMask(session: MaskSession): MaskSession {
return { ...session, state: "confirmed" };
}
export function requestRetake(session: MaskSession): MaskSession {
return { ...session, state: "retake" };
}
export {
GREEN_HUE_LO,
GREEN_HUE_HI,
GREEN_SAT_MIN,
GREEN_VAL_MIN,
BLACK_THRESH,
};
+39
View File
@@ -0,0 +1,39 @@
export type SheetMode = "auto" | "green" | "black";
export type ResolvedSheet = "green" | "black";
export interface KnobSettings {
sheet: SheetMode;
/** 0..1 — looser sheet detection at higher values. */
sensitivity: number;
/** Signed pixels: positive pulls mask inward onto wood. */
edgeOffset: number;
/** Soft alpha blend width in pixels. */
feather: number;
}
export interface RgbaImage {
width: number;
height: number;
/** Row-major RGBA, length width*height*4. */
data: Uint8ClampedArray;
}
/** 0 = removed background, 255 = kept slab (or intermediate for feather). */
export type MaskBuffer = Uint8Array;
export type MaskConfirmState = "pending" | "confirmed" | "retake";
export interface MaskResult {
mask: MaskBuffer;
width: number;
height: number;
sheetUsed: ResolvedSheet;
/** POC contract: always false (U2Net deferred). */
u2netInvoked: false;
knobs: KnobSettings;
}
export interface MaskSession {
result: MaskResult;
state: MaskConfirmState;
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vitest/globals"],
"lib": ["ES2022"]
},
"include": ["src/**/*.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/__tests__/**/*.test.ts'],
},
});
+10
View File
@@ -0,0 +1,10 @@
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
✓ src/__tests__/tv1-tv2-mask.test.ts (5 tests) 34ms
Test Files 1 passed (1)
Tests 5 passed (5)
Start at 05:53:31
Duration 456ms (transform 81ms, setup 0ms, collect 85ms, tests 34ms, environment 0ms, prepare 64ms)
+87
View File
@@ -0,0 +1,87 @@
RUN v2.1.9 /workspace/gitea-atd/Ty_Tech/SlabUploader/frontend
❯ src/__tests__/tv1-tv2-mask.test.ts (5 tests | 5 failed) 14ms
× TV-1 green-sheet chroma-key > keeps interior green-ish patch that does not touch the border 8ms
→ removeSheetBackground not implemented
× TV-1 green-sheet chroma-key > re-runs live when knobs change and still skips U2Net 1ms
→ removeSheetBackground not implemented
× TV-2 black-sheet threshold + border flood-fill > keeps dark interior wood that does not touch the border 1ms
→ removeSheetBackground not implemented
× TV-2 black-sheet threshold + border flood-fill > flood-fill removes only border-connected dark pixels 1ms
→ removeSheetBackground not implemented
× mask confirm hooks (pure) > starts pending, confirm and retake transition without DOM 2ms
→ removeSheetBackground not implemented
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-1 green-sheet chroma-key > keeps interior green-ish patch that does not touch the border
Error: removeSheetBackground not implemented
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
29| _knobs: KnobSettings = defaultKnobs(),
30| ): MaskResult {
31| throw new Error("removeSheetBackground not implemented");
| ^
32| }
33|
❯ src/__tests__/tv1-tv2-mask.test.ts:20:20
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/5]⎯
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-1 green-sheet chroma-key > re-runs live when knobs change and still skips U2Net
Error: removeSheetBackground not implemented
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
29| _knobs: KnobSettings = defaultKnobs(),
30| ): MaskResult {
31| throw new Error("removeSheetBackground not implemented");
| ^
32| }
33|
❯ src/__tests__/tv1-tv2-mask.test.ts:33:19
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-2 black-sheet threshold + border flood-fill > keeps dark interior wood that does not touch the border
Error: removeSheetBackground not implemented
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
29| _knobs: KnobSettings = defaultKnobs(),
30| ): MaskResult {
31| throw new Error("removeSheetBackground not implemented");
| ^
32| }
33|
❯ src/__tests__/tv1-tv2-mask.test.ts:51:20
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯
FAIL src/__tests__/tv1-tv2-mask.test.ts > TV-2 black-sheet threshold + border flood-fill > flood-fill removes only border-connected dark pixels
Error: removeSheetBackground not implemented
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
29| _knobs: KnobSettings = defaultKnobs(),
30| ): MaskResult {
31| throw new Error("removeSheetBackground not implemented");
| ^
32| }
33|
❯ src/__tests__/tv1-tv2-mask.test.ts:64:20
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯
FAIL src/__tests__/tv1-tv2-mask.test.ts > mask confirm hooks (pure) > starts pending, confirm and retake transition without DOM
Error: removeSheetBackground not implemented
❯ Module.removeSheetBackground src/lib/pipeline/mask.ts:31:9
29| _knobs: KnobSettings = defaultKnobs(),
30| ): MaskResult {
31| throw new Error("removeSheetBackground not implemented");
| ^
32| }
33|
❯ src/__tests__/tv1-tv2-mask.test.ts:76:20
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/5]⎯
Test Files 1 failed (1)
Tests 5 failed (5)
Start at 05:52:50
Duration 383ms (transform 68ms, setup 0ms, collect 70ms, tests 14ms, environment 0ms, prepare 67ms)