bgcut 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # bgcut
2
+
3
+ Local background removal with WebGPU as the primary execution path.
4
+
5
+ The browser implementation uses a pinned BiRefNet Lite 512 ONNX model with ONNX Runtime WebGPU, shares one application-owned `GPUDevice` with TypeGPU, captures the inference graph, and exports transparent PNGs at the source image resolution.
6
+
7
+ ## Development
8
+
9
+ ```sh
10
+ bun install --frozen-lockfile
11
+ bun run dev
12
+ ```
13
+
14
+ Run the full project checks with:
15
+
16
+ ```sh
17
+ bun run check
18
+ ```
19
+
20
+ That runs oxlint, TypeScript, product tests, the production build, and an npm package dry run. Vendored anti-slop maintainer tests are excluded from Bun's project-level test discovery through `bunfig.toml` because Oxlint `RuleTester` expects its upstream Node/tsx environment.
21
+
22
+ ## Browser pipeline
23
+
24
+ ```text
25
+ image
26
+ -> source decode
27
+ -> TypeGPU resize + ImageNet normalization on the shared WebGPU device
28
+ -> BiRefNet Lite ONNX inference with WebGPU graph capture
29
+ -> GPU output readback
30
+ -> alpha matte
31
+ -> source-resolution compositing
32
+ -> transparent PNG
33
+ ```
34
+
35
+ The model artifact is verified by exact byte count and SHA-256 during production builds. Source images never go to an inference backend.
36
+
37
+ ## Native CLI
38
+
39
+ The first npm release is published on the `beta` tag. The CLI currently runs on Bun, so install Bun before installing or invoking `bgcut`.
40
+
41
+ Install the beta globally with npm:
42
+
43
+ ```sh
44
+ npm install -g bgcut@beta
45
+ ```
46
+
47
+ Or with Bun:
48
+
49
+ ```sh
50
+ bun add -g bgcut@beta
51
+ ```
52
+
53
+ For a one-off run without a global install:
54
+
55
+ ```sh
56
+ bunx bgcut@beta photo.jpg
57
+ ```
58
+
59
+ The canonical command is `bgcut`:
60
+
61
+ ```sh
62
+ bgcut photo.jpg
63
+ bgcut photo.jpg --png
64
+ bgcut photo.jpg -png
65
+ bgcut photo.jpg --webp
66
+ bgcut photo.jpg -webp -o portrait.webp
67
+ bgcut photo.jpg -o portrait.png
68
+ ```
69
+
70
+ From a source checkout, `bun run cli -- ...` runs the same entrypoint without installing the package binary.
71
+
72
+ The format itself is the option; there is deliberately no `--format png` syntax. PNG is the default. WebP is encoded losslessly. JPG/JPEG is supported, but because JPEG has no alpha channel it is flattened onto white.
73
+
74
+ The CLI uses the same validated ONNX model and shared normalization/matte functions. It tries native ONNX Runtime WebGPU in automatic mode and falls back to native CPU execution if WebGPU session creation is unavailable. Use `-gpu`/`--gpu` or `-cpu`/`--cpu` to require one engine while validating the native path. The selected engine is printed after each run.
75
+
76
+ The CLI does not launch Chromium and does not upload the input image. It caches the exact validated model in the operating system's user cache directory and verifies the model before use.
77
+
78
+ ## BG0 comparison
79
+
80
+ After this build removes a background, the browser result uses a draggable comparison slider rather than two independent panes. By default it compares the original image with this build's result.
81
+
82
+ Use **Load BG0 output** to select a BG0 result from disk. The file stays local. The comparison requires the BG0 image to have the same width and height as this build's output so the two results remain pixel-aligned while dragging the divider.
83
+
84
+ This comparison is intended for local acceptance of hair, fur, whiskers, thin edges, holes, and semi-transparent boundaries. It is a visual inspection tool, not a substitute for numeric matte regression tests.
85
+
86
+ ## Direction
87
+
88
+ Local execution alone is not a differentiator from projects such as BG0. The intended differentiation is lower-level ownership of the GPU pipeline and the editing/refinement capabilities that ownership makes possible. Performance and output-quality claims must be benchmarked rather than assumed.
89
+
90
+ The target is a GPU-native cutout editor rather than only a one-shot background remover. Near-term work includes stronger job/cancellation APIs, quality refinement, non-destructive restore/erase editing, compatibility fallback, persistence, and benchmark coverage.
91
+
92
+ See [`IMPROVEMENTS.md`](IMPROVEMENTS.md) for the prioritized roadmap, competitive baseline, benchmark plan, architecture direction, and exact manual-acceptance baseline.
93
+
94
+ ## Model
95
+
96
+ - Model: `studioludens/birefnet-lite-512`
97
+ - Revision: `4a3c40c36c94093cc1e724d9ea428b8fa4b57dc7`
98
+ - Validated runtime artifact: `birefnet-lite-512-ort-basic-webgpu-v2.onnx`
99
+ - Artifact size: `195,872,736` bytes
100
+ - SHA-256: `4461109672dda07a054892aef076b5fcc5fc40bbc91f51a357a7593c7f45ad9c`
101
+ - Inference size: 512×512
102
+ - Export size: original source dimensions
103
+
104
+ ## Privacy
105
+
106
+ Source images, decoded pixels, masks, and generated outputs stay on the user's machine. The model is downloaded from the pinned release artifact; source images are not sent there or to an application inference backend.
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "bgcut",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Local background removal CLI with native WebGPU and CPU fallback.",
5
+ "type": "module",
6
+ "packageManager": "bun@1.4.2",
7
+ "bin": {
8
+ "bgcut": "src/cli/main.ts"
9
+ },
10
+ "files": [
11
+ "src/cli/alpha-mask.ts",
12
+ "src/cli/args.ts",
13
+ "src/cli/main.ts",
14
+ "src/cli/model-cache.ts",
15
+ "src/cli/runtime.ts",
16
+ "src/engine/errors.ts",
17
+ "src/engine/image.ts",
18
+ "src/engine/matte.ts",
19
+ "src/engine/model-config.ts",
20
+ "src/engine/preprocess.ts",
21
+ "src/shared/model-file.ts"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/jhomra21/bgremove.git"
26
+ },
27
+ "homepage": "https://github.com/jhomra21/bgremove#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/jhomra21/bgremove/issues"
30
+ },
31
+ "keywords": [
32
+ "background-removal",
33
+ "image-processing",
34
+ "webgpu",
35
+ "onnx",
36
+ "cli",
37
+ "local-ai"
38
+ ],
39
+ "publishConfig": {
40
+ "tag": "beta"
41
+ },
42
+ "scripts": {
43
+ "dev": "vite",
44
+ "cli": "bun run src/cli/main.ts",
45
+ "model:prepare": "bun run scripts/prepare-model.ts",
46
+ "model:verify-dist": "bun run scripts/verify-model.ts",
47
+ "build": "bun run model:prepare && vite build && bun run model:verify-dist",
48
+ "preview": "vite preview",
49
+ "lint": "oxlint .",
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "bun test",
52
+ "package:dry-run": "npm pack --dry-run",
53
+ "package:smoke": "bun run scripts/package-smoke.ts",
54
+ "check": "bun run lint && bun run typecheck && bun run test && bun run build && bun run package:dry-run && bun run package:smoke"
55
+ },
56
+ "dependencies": {
57
+ "effect": "3.22.2",
58
+ "onnxruntime-node": "1.30.0",
59
+ "sharp": "0.35.4"
60
+ },
61
+ "devDependencies": {
62
+ "@oxlint/plugins": "1.83.0",
63
+ "@solidjs/vite-plugin": "3.0.0-next.43",
64
+ "@solidjs/web": "2.0.0-rc.8",
65
+ "@types/bun": "1.4.2",
66
+ "@typescript/lib-dom": "npm:@types/web@0.0.356",
67
+ "onnxruntime-web": "1.30.0",
68
+ "oxlint": "1.83.0",
69
+ "solid-js": "2.0.0-rc.8",
70
+ "typegpu": "0.12.5",
71
+ "typescript": "7.0.2",
72
+ "vite": "8.3.0"
73
+ }
74
+ }
@@ -0,0 +1,28 @@
1
+ export const compositeAlphaMask = (
2
+ rgba: Uint8Array,
3
+ mask: Uint8Array,
4
+ maskChannels: number,
5
+ ): void => {
6
+ if (!Number.isInteger(maskChannels) || maskChannels < 1) {
7
+ throw new Error(`Mask channel count must be a positive integer; received ${maskChannels}.`);
8
+ }
9
+
10
+ if (rgba.length % 4 !== 0) {
11
+ throw new Error(`RGBA byte length must be divisible by 4; received ${rgba.length}.`);
12
+ }
13
+
14
+ const pixelCount = rgba.length / 4;
15
+ const requiredMaskBytes = pixelCount * maskChannels;
16
+
17
+ if (mask.length < requiredMaskBytes) {
18
+ throw new Error(`Mask contains ${mask.length} bytes; expected at least ${requiredMaskBytes}.`);
19
+ }
20
+
21
+ for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
22
+ const rgbaAlphaIndex = pixelIndex * 4 + 3;
23
+ const maskIndex = pixelIndex * maskChannels;
24
+ const maskAlpha = mask[maskIndex];
25
+
26
+ rgba[rgbaAlphaIndex] = Math.round((rgba[rgbaAlphaIndex] * maskAlpha) / 255);
27
+ }
28
+ };
@@ -0,0 +1,192 @@
1
+ import { Data, Effect } from "effect";
2
+ import { dirname, extname, join, parse } from "node:path";
3
+
4
+ export type CliFormat = "png" | "webp" | "jpg";
5
+
6
+ export type CliEngine = "auto" | "gpu" | "cpu";
7
+
8
+ export type CliOptions = {
9
+ readonly inputPath: string;
10
+ readonly outputPath: string;
11
+ readonly format: CliFormat;
12
+ readonly engine: CliEngine;
13
+ };
14
+
15
+ export type ParsedCli =
16
+ | { readonly kind: "help" }
17
+ | { readonly kind: "run"; readonly options: CliOptions };
18
+
19
+ export class CliArgumentError extends Data.TaggedError("CliArgumentError")<{
20
+ readonly message: string;
21
+ }> {}
22
+
23
+ const formatFlags = new Map<string, CliFormat>([
24
+ ["--png", "png"],
25
+ ["-png", "png"],
26
+ ["--webp", "webp"],
27
+ ["-webp", "webp"],
28
+ ["--jpg", "jpg"],
29
+ ["-jpg", "jpg"],
30
+ ["--jpeg", "jpg"],
31
+ ["-jpeg", "jpg"],
32
+ ]);
33
+
34
+ const engineFlags = new Map<string, CliEngine>([
35
+ ["--gpu", "gpu"],
36
+ ["-gpu", "gpu"],
37
+ ["--cpu", "cpu"],
38
+ ["-cpu", "cpu"],
39
+ ]);
40
+
41
+ const formatFromExtension = (path: string): CliFormat | undefined => {
42
+ const extension = extname(path).toLowerCase();
43
+
44
+ if (extension === ".png") {
45
+ return "png";
46
+ }
47
+
48
+ if (extension === ".webp") {
49
+ return "webp";
50
+ }
51
+
52
+ if (extension === ".jpg" || extension === ".jpeg") {
53
+ return "jpg";
54
+ }
55
+
56
+ return undefined;
57
+ };
58
+
59
+ const extensionForFormat = (format: CliFormat): string => `.${format}`;
60
+
61
+ const defaultOutputPath = (inputPath: string, format: CliFormat): string => {
62
+ const parsed = parse(inputPath);
63
+ const baseName = parsed.name.length > 0 ? parsed.name : "image";
64
+
65
+ return join(dirname(inputPath), `${baseName}-nobg${extensionForFormat(format)}`);
66
+ };
67
+
68
+ const resolveOutputPath = (
69
+ inputPath: string,
70
+ requestedOutput: string | undefined,
71
+ requestedFormat: CliFormat | undefined,
72
+ ): Effect.Effect<{ readonly outputPath: string; readonly format: CliFormat }, CliArgumentError> =>
73
+ Effect.gen(function* () {
74
+ if (requestedOutput === undefined) {
75
+ const format = requestedFormat ?? "png";
76
+
77
+ return { outputPath: defaultOutputPath(inputPath, format), format };
78
+ }
79
+
80
+ const extension = extname(requestedOutput);
81
+ const outputFormat = formatFromExtension(requestedOutput);
82
+
83
+ if (extension.length > 0 && outputFormat === undefined) {
84
+ return yield* new CliArgumentError({
85
+ message: `Unsupported output extension "${extension}". Use PNG, WebP, JPG, or JPEG.`,
86
+ });
87
+ }
88
+
89
+ if (requestedFormat !== undefined && outputFormat !== undefined && requestedFormat !== outputFormat) {
90
+ return yield* new CliArgumentError({
91
+ message: `Output path "${requestedOutput}" conflicts with the requested --${requestedFormat} format.`,
92
+ });
93
+ }
94
+
95
+ const format = requestedFormat ?? outputFormat ?? "png";
96
+
97
+ const outputPath = extension.length === 0
98
+ ? `${requestedOutput}${extensionForFormat(format)}`
99
+ : requestedOutput;
100
+
101
+ return { outputPath, format };
102
+ });
103
+
104
+ export const parseCliArgs = (args: readonly string[]): Effect.Effect<ParsedCli, CliArgumentError> =>
105
+ Effect.gen(function* () {
106
+ let inputPath: string | undefined;
107
+ let outputPath: string | undefined;
108
+ let format: CliFormat | undefined;
109
+ let engine: CliEngine = "auto";
110
+
111
+ for (let index = 0; index < args.length; index += 1) {
112
+ const argument = args[index];
113
+
114
+ if (argument === "--") {
115
+ continue;
116
+ }
117
+
118
+ if (argument === "-h" || argument === "--help") {
119
+ return { kind: "help" };
120
+ }
121
+
122
+ if (argument === "--format" || argument === "-f") {
123
+ return yield* new CliArgumentError({
124
+ message: "Use the format itself as a flag: --png, --webp, --jpg, -png, -webp, or -jpg.",
125
+ });
126
+ }
127
+
128
+ if (argument === "-o" || argument === "--output") {
129
+ const next = args[index + 1];
130
+
131
+ if (next === undefined || next.startsWith("-")) {
132
+ return yield* new CliArgumentError({ message: `${argument} requires an output path.` });
133
+ }
134
+
135
+ if (outputPath !== undefined) {
136
+ return yield* new CliArgumentError({ message: "Only one output path can be specified." });
137
+ }
138
+
139
+ outputPath = next;
140
+ index += 1;
141
+ continue;
142
+ }
143
+
144
+ const requestedFormat = formatFlags.get(argument);
145
+
146
+ if (requestedFormat !== undefined) {
147
+ if (format !== undefined && format !== requestedFormat) {
148
+ return yield* new CliArgumentError({ message: "Only one output format can be specified." });
149
+ }
150
+
151
+ format = requestedFormat;
152
+ continue;
153
+ }
154
+
155
+ const requestedEngine = engineFlags.get(argument);
156
+
157
+ if (requestedEngine !== undefined) {
158
+ if (engine !== "auto" && engine !== requestedEngine) {
159
+ return yield* new CliArgumentError({ message: "Choose either GPU or CPU execution, not both." });
160
+ }
161
+
162
+ engine = requestedEngine;
163
+ continue;
164
+ }
165
+
166
+ if (argument.startsWith("-")) {
167
+ return yield* new CliArgumentError({ message: `Unknown option "${argument}".` });
168
+ }
169
+
170
+ if (inputPath !== undefined) {
171
+ return yield* new CliArgumentError({ message: "Only one input image can be processed per command for now." });
172
+ }
173
+
174
+ inputPath = argument;
175
+ }
176
+
177
+ if (inputPath === undefined) {
178
+ return yield* new CliArgumentError({ message: "An input image path is required." });
179
+ }
180
+
181
+ const output = yield* resolveOutputPath(inputPath, outputPath, format);
182
+
183
+ return {
184
+ kind: "run",
185
+ options: {
186
+ inputPath,
187
+ outputPath: output.outputPath,
188
+ format: output.format,
189
+ engine,
190
+ },
191
+ };
192
+ });
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Cause, Effect, Exit } from "effect";
4
+
5
+ import { parseCliArgs } from "./args";
6
+ import { removeBackgroundCli } from "./runtime";
7
+
8
+ const HELP = `Usage:
9
+ bgcut <image> [format] [options]
10
+
11
+ Formats:
12
+ --png, -png Transparent PNG (default)
13
+ --webp, -webp Lossless WebP with transparency
14
+ --jpg, -jpg JPEG flattened onto white
15
+ --jpeg, -jpeg Alias for JPG
16
+
17
+ Options:
18
+ -o, --output <path> Output filename or path
19
+ --gpu, -gpu Require native WebGPU
20
+ --cpu, -cpu Require CPU inference
21
+ -h, --help Show this help
22
+
23
+ Examples:
24
+ bgcut photo.jpg -png
25
+ bgcut photo.jpg --webp
26
+ bgcut photo.jpg -o portrait.png
27
+ bgcut photo.jpg -webp -o portrait.webp
28
+ bgcut photo.jpg -gpu
29
+ `;
30
+
31
+ const formatDuration = (milliseconds: number): string => {
32
+ if (milliseconds < 10) {
33
+ return `${milliseconds.toFixed(1)} ms`;
34
+ }
35
+
36
+ if (milliseconds < 1000) {
37
+ return `${Math.round(milliseconds)} ms`;
38
+ }
39
+
40
+ return `${(milliseconds / 1000).toFixed(2)} s`;
41
+ };
42
+
43
+ const program = Effect.gen(function* () {
44
+ const parsed = yield* parseCliArgs(process.argv.slice(2));
45
+
46
+ if (parsed.kind === "help") {
47
+ console.log(HELP);
48
+
49
+ return;
50
+ }
51
+
52
+ const result = yield* removeBackgroundCli(parsed.options);
53
+ const timings = result.timings;
54
+
55
+ console.log(`✓ ${result.engine} · ${result.width}×${result.height} · saved ${result.outputPath}`);
56
+
57
+ if (result.fallbackReason !== undefined) {
58
+ console.log(`↳ WebGPU unavailable; automatic mode used CPU. ${result.fallbackReason}`);
59
+ }
60
+
61
+ console.log(
62
+ ` model ${formatDuration(timings.modelMs)} · prepare ${formatDuration(timings.prepareMs)} · session ${formatDuration(timings.sessionMs)} · inference ${formatDuration(timings.inferenceMs)} · encode ${formatDuration(timings.encodeMs)} · total ${formatDuration(timings.totalMs)}`,
63
+ );
64
+ });
65
+
66
+ const exit = await Effect.runPromiseExit(program);
67
+
68
+ if (Exit.isFailure(exit)) {
69
+ console.error(Cause.pretty(exit.cause));
70
+ process.exitCode = 1;
71
+ }
@@ -0,0 +1,122 @@
1
+ import { Data, Effect } from "effect";
2
+ import { mkdir, rename, rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import {
7
+ MODEL_FILENAME,
8
+ MODEL_RELEASE_URL,
9
+ MODEL_SHA256,
10
+ MODEL_SIZE_BYTES,
11
+ } from "../engine/model-config";
12
+ import { inspectModelFile, type ModelFileFingerprint } from "../shared/model-file";
13
+
14
+ export class CliModelError extends Data.TaggedError("CliModelError")<{
15
+ readonly message: string;
16
+ readonly cause?: unknown;
17
+ }> {}
18
+
19
+ const cacheRoot = (appName: string): string => {
20
+ if (process.platform === "darwin") {
21
+ return join(homedir(), "Library", "Caches", appName);
22
+ }
23
+
24
+ if (process.platform === "win32") {
25
+ const localAppData = process.env.LOCALAPPDATA;
26
+
27
+ return join(localAppData ?? join(homedir(), "AppData", "Local"), appName);
28
+ }
29
+
30
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), appName);
31
+ };
32
+
33
+ export const cliModelPath = (): string => join(cacheRoot("bgcut"), "models", MODEL_FILENAME);
34
+
35
+ const previousCliModelPaths = (): readonly string[] => [
36
+ join(cacheRoot("bgremove"), "models", MODEL_FILENAME),
37
+ join(cacheRoot("removebg-webgpu"), "models", MODEL_FILENAME),
38
+ ];
39
+
40
+ const isExpectedModel = (fingerprint: ModelFileFingerprint | undefined): boolean =>
41
+ fingerprint?.sizeBytes === MODEL_SIZE_BYTES && fingerprint.sha256 === MODEL_SHA256;
42
+
43
+ const inspectCachedModel = (path: string): Effect.Effect<ModelFileFingerprint | undefined, CliModelError> =>
44
+ inspectModelFile(path).pipe(
45
+ Effect.mapError((cause) =>
46
+ new CliModelError({ message: `Could not inspect the cached model at ${path}.`, cause }),
47
+ ),
48
+ );
49
+
50
+ export const ensureCliModel = (): Effect.Effect<string, CliModelError> =>
51
+ Effect.gen(function* () {
52
+ const modelPath = cliModelPath();
53
+ const existing = yield* inspectCachedModel(modelPath);
54
+
55
+ if (isExpectedModel(existing)) {
56
+ return modelPath;
57
+ }
58
+
59
+ for (const previousModelPath of previousCliModelPaths()) {
60
+ const previousExisting = yield* inspectCachedModel(previousModelPath);
61
+
62
+ if (isExpectedModel(previousExisting)) {
63
+ return previousModelPath;
64
+ }
65
+ }
66
+
67
+ const temporaryPath = `${modelPath}.download`;
68
+
69
+ yield* Effect.tryPromise({
70
+ try: () => mkdir(dirname(modelPath), { recursive: true }),
71
+ catch: (cause) => new CliModelError({ message: `Could not create ${dirname(modelPath)}.`, cause }),
72
+ });
73
+
74
+ yield* Effect.tryPromise({
75
+ try: () => rm(temporaryPath, { force: true }),
76
+ catch: (cause) => new CliModelError({ message: `Could not clear ${temporaryPath}.`, cause }),
77
+ });
78
+
79
+ const response = yield* Effect.tryPromise({
80
+ try: () => fetch(MODEL_RELEASE_URL),
81
+ catch: (cause) =>
82
+ new CliModelError({ message: "Could not download the validated BiRefNet model.", cause }),
83
+ });
84
+
85
+ if (!response.ok) {
86
+ return yield* new CliModelError({
87
+ message: `Validated model download failed with HTTP ${response.status}.`,
88
+ });
89
+ }
90
+
91
+ yield* Effect.tryPromise({
92
+ try: () => Bun.write(temporaryPath, response),
93
+ catch: (cause) => new CliModelError({ message: `Could not write ${temporaryPath}.`, cause }),
94
+ });
95
+
96
+ const downloaded = yield* inspectModelFile(temporaryPath).pipe(
97
+ Effect.mapError((cause) =>
98
+ new CliModelError({ message: `Could not verify ${temporaryPath}.`, cause }),
99
+ ),
100
+ );
101
+
102
+ if (!isExpectedModel(downloaded)) {
103
+ yield* Effect.tryPromise({
104
+ try: () => rm(temporaryPath, { force: true }),
105
+ catch: (cause) => new CliModelError({ message: `Could not remove invalid ${temporaryPath}.`, cause }),
106
+ });
107
+
108
+ return yield* new CliModelError({
109
+ message: `Downloaded model did not match the expected ${MODEL_SIZE_BYTES}-byte artifact with SHA-256 ${MODEL_SHA256}.`,
110
+ });
111
+ }
112
+
113
+ yield* Effect.tryPromise({
114
+ try: async () => {
115
+ await rm(modelPath, { force: true });
116
+ await rename(temporaryPath, modelPath);
117
+ },
118
+ catch: (cause) => new CliModelError({ message: `Could not install the model at ${modelPath}.`, cause }),
119
+ });
120
+
121
+ return modelPath;
122
+ });
@@ -0,0 +1,347 @@
1
+ import { Data, Effect } from "effect";
2
+ import * as ort from "onnxruntime-node";
3
+ import { mkdir } from "node:fs/promises";
4
+ import { dirname, resolve } from "node:path";
5
+ import sharp from "sharp";
6
+
7
+ import { MODEL_INPUT_SIZE } from "../engine/image";
8
+ import { logitToAlphaByte } from "../engine/matte";
9
+ import { resizeRgbaLinearToNchw } from "../engine/preprocess";
10
+ import { compositeAlphaMask } from "./alpha-mask";
11
+ import type { CliEngine, CliFormat, CliOptions } from "./args";
12
+ import { CliModelError, ensureCliModel } from "./model-cache";
13
+
14
+ export type CliExecutionEngine = "webgpu" | "cpu";
15
+
16
+ export type CliTimings = {
17
+ readonly totalMs: number;
18
+ readonly modelMs: number;
19
+ readonly prepareMs: number;
20
+ readonly sessionMs: number;
21
+ readonly inferenceMs: number;
22
+ readonly encodeMs: number;
23
+ };
24
+
25
+ export type CliRemovalResult = {
26
+ readonly width: number;
27
+ readonly height: number;
28
+ readonly outputPath: string;
29
+ readonly engine: CliExecutionEngine;
30
+ readonly fallbackReason: string | undefined;
31
+ readonly timings: CliTimings;
32
+ };
33
+
34
+ export class CliImageError extends Data.TaggedError("CliImageError")<{
35
+ readonly message: string;
36
+ readonly cause?: unknown;
37
+ }> {}
38
+
39
+ export class CliSessionError extends Data.TaggedError("CliSessionError")<{
40
+ readonly message: string;
41
+ readonly cause?: unknown;
42
+ }> {}
43
+
44
+ export class CliInferenceError extends Data.TaggedError("CliInferenceError")<{
45
+ readonly message: string;
46
+ readonly cause?: unknown;
47
+ }> {}
48
+
49
+ export class CliOutputError extends Data.TaggedError("CliOutputError")<{
50
+ readonly message: string;
51
+ readonly cause?: unknown;
52
+ }> {}
53
+
54
+ type PreparedImage = {
55
+ readonly source: Buffer;
56
+ readonly width: number;
57
+ readonly height: number;
58
+ readonly modelInput: Float32Array;
59
+ };
60
+
61
+ type NativeSession = {
62
+ readonly session: ort.InferenceSession;
63
+ readonly engine: CliExecutionEngine;
64
+ readonly fallbackReason: string | undefined;
65
+ };
66
+
67
+ const supportedSharpFormats = new Set(["jpeg", "png", "webp"]);
68
+
69
+ const prepareImage = (inputPath: string): Effect.Effect<PreparedImage, CliImageError> =>
70
+ Effect.tryPromise({
71
+ try: async () => {
72
+ const input = Bun.file(inputPath);
73
+
74
+ if (!(await input.exists())) {
75
+ throw new Error(`Input file does not exist: ${inputPath}`);
76
+ }
77
+
78
+ const metadata = await sharp(inputPath).metadata();
79
+
80
+ if (metadata.format === undefined || !supportedSharpFormats.has(metadata.format)) {
81
+ throw new Error(`Unsupported image type: ${metadata.format ?? "unknown"}`);
82
+ }
83
+
84
+ const source = await sharp(inputPath)
85
+ .rotate()
86
+ .ensureAlpha()
87
+ .toColourspace("srgb")
88
+ .raw()
89
+ .toBuffer({ resolveWithObject: true });
90
+
91
+ if (source.info.channels !== 4) {
92
+ throw new Error("Decoded image did not produce RGBA pixels.");
93
+ }
94
+
95
+ return {
96
+ source: source.data,
97
+ width: source.info.width,
98
+ height: source.info.height,
99
+ modelInput: resizeRgbaLinearToNchw(
100
+ source.data,
101
+ source.info.width,
102
+ source.info.height,
103
+ MODEL_INPUT_SIZE,
104
+ MODEL_INPUT_SIZE,
105
+ ),
106
+ };
107
+ },
108
+ catch: (cause) =>
109
+ new CliImageError({
110
+ message: `Could not decode and prepare ${inputPath}.`,
111
+ cause,
112
+ }),
113
+ });
114
+
115
+ const createSessionForProvider = (
116
+ modelPath: string,
117
+ engine: CliExecutionEngine,
118
+ ): Effect.Effect<NativeSession, CliSessionError> =>
119
+ Effect.tryPromise({
120
+ try: async () => ({
121
+ session: await ort.InferenceSession.create(modelPath, {
122
+ executionProviders: [engine],
123
+ graphOptimizationLevel: "all",
124
+ }),
125
+ engine,
126
+ fallbackReason: undefined,
127
+ }),
128
+ catch: (cause) =>
129
+ new CliSessionError({
130
+ message: `ONNX Runtime could not create the native ${engine} session.`,
131
+ cause,
132
+ }),
133
+ });
134
+
135
+ const createSession = (
136
+ modelPath: string,
137
+ engine: CliEngine,
138
+ ): Effect.Effect<NativeSession, CliSessionError> => {
139
+ if (engine === "gpu") {
140
+ return createSessionForProvider(modelPath, "webgpu");
141
+ }
142
+
143
+ if (engine === "cpu") {
144
+ return createSessionForProvider(modelPath, "cpu");
145
+ }
146
+
147
+ return createSessionForProvider(modelPath, "webgpu").pipe(
148
+ Effect.catchAll((webGpuError) =>
149
+ createSessionForProvider(modelPath, "cpu").pipe(
150
+ Effect.map((nativeSession) => ({
151
+ ...nativeSession,
152
+ fallbackReason: webGpuError.message,
153
+ })),
154
+ ),
155
+ ),
156
+ );
157
+ };
158
+
159
+ const runInference = (
160
+ session: ort.InferenceSession,
161
+ modelInput: Float32Array,
162
+ ): Effect.Effect<Float32Array, CliInferenceError> =>
163
+ Effect.gen(function* () {
164
+ const inputName = session.inputNames.at(0);
165
+ const outputName = session.outputNames.at(0);
166
+
167
+ if (inputName === undefined || outputName === undefined) {
168
+ return yield* new CliInferenceError({
169
+ message: "BiRefNet does not expose the expected input and output tensors.",
170
+ });
171
+ }
172
+
173
+ const input = new ort.Tensor("float32", modelInput, [1, 3, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE]);
174
+
175
+ const outputs = yield* Effect.tryPromise({
176
+ try: () => session.run({ [inputName]: input }),
177
+ catch: (cause) => new CliInferenceError({ message: "BiRefNet inference failed.", cause }),
178
+ });
179
+
180
+ const output = outputs[outputName];
181
+
182
+ if (output === undefined) {
183
+ return yield* new CliInferenceError({ message: "BiRefNet returned no foreground matte." });
184
+ }
185
+
186
+ const data = output.data;
187
+
188
+ if (!(data instanceof Float32Array)) {
189
+ return yield* new CliInferenceError({
190
+ message: `BiRefNet returned ${output.type} data instead of float32 logits.`,
191
+ });
192
+ }
193
+
194
+ if (data.length !== MODEL_INPUT_SIZE * MODEL_INPUT_SIZE) {
195
+ return yield* new CliInferenceError({
196
+ message: `BiRefNet returned ${data.length} logits instead of ${MODEL_INPUT_SIZE * MODEL_INPUT_SIZE}.`,
197
+ });
198
+ }
199
+
200
+ return data;
201
+ });
202
+
203
+ const createMask = (logits: Float32Array): Uint8Array => {
204
+ const alpha = new Uint8Array(logits.length);
205
+
206
+ for (let index = 0; index < logits.length; index += 1) {
207
+ alpha[index] = logitToAlphaByte(logits[index]);
208
+ }
209
+
210
+ return alpha;
211
+ };
212
+
213
+ const encodeOutput = (
214
+ prepared: PreparedImage,
215
+ logits: Float32Array,
216
+ outputPath: string,
217
+ format: CliFormat,
218
+ ): Effect.Effect<void, CliOutputError> =>
219
+ Effect.tryPromise({
220
+ try: async () => {
221
+ const alpha = createMask(logits);
222
+
223
+ const resizedAlpha = await sharp(Buffer.from(alpha), {
224
+ raw: {
225
+ width: MODEL_INPUT_SIZE,
226
+ height: MODEL_INPUT_SIZE,
227
+ channels: 1,
228
+ },
229
+ })
230
+ .resize(prepared.width, prepared.height, {
231
+ fit: "fill",
232
+ kernel: sharp.kernel.cubic,
233
+ fastShrinkOnLoad: false,
234
+ })
235
+ .raw()
236
+ .toBuffer({ resolveWithObject: true });
237
+
238
+ if (resizedAlpha.info.width !== prepared.width || resizedAlpha.info.height !== prepared.height) {
239
+ throw new Error(
240
+ `Resized matte is ${resizedAlpha.info.width} × ${resizedAlpha.info.height}; expected ${prepared.width} × ${prepared.height}.`,
241
+ );
242
+ }
243
+
244
+ const rgba = Buffer.from(prepared.source);
245
+
246
+ compositeAlphaMask(rgba, resizedAlpha.data, resizedAlpha.info.channels);
247
+
248
+ await mkdir(dirname(outputPath), { recursive: true });
249
+
250
+ const image = sharp(rgba, {
251
+ raw: {
252
+ width: prepared.width,
253
+ height: prepared.height,
254
+ channels: 4,
255
+ },
256
+ });
257
+
258
+ if (format === "png") {
259
+ await image.png().toFile(outputPath);
260
+
261
+ return;
262
+ }
263
+
264
+ if (format === "webp") {
265
+ await image.webp({ lossless: true }).toFile(outputPath);
266
+
267
+ return;
268
+ }
269
+
270
+ await image
271
+ .flatten({ background: { r: 255, g: 255, b: 255 } })
272
+ .jpeg({ quality: 95, chromaSubsampling: "4:4:4" })
273
+ .toFile(outputPath);
274
+ },
275
+ catch: (cause) =>
276
+ new CliOutputError({
277
+ message: `Could not encode ${outputPath}.`,
278
+ cause,
279
+ }),
280
+ });
281
+
282
+ export const removeBackgroundCli = (
283
+ options: CliOptions,
284
+ ): Effect.Effect<
285
+ CliRemovalResult,
286
+ CliImageError | CliSessionError | CliInferenceError | CliOutputError | CliModelError
287
+ > =>
288
+ Effect.gen(function* () {
289
+ if (resolve(options.inputPath) === resolve(options.outputPath)) {
290
+ return yield* new CliOutputError({
291
+ message: "Input and output paths must be different.",
292
+ });
293
+ }
294
+
295
+ const totalStartedAt = performance.now();
296
+ let stageStartedAt = performance.now();
297
+
298
+ const modelPath = yield* ensureCliModel();
299
+ const modelMs = performance.now() - stageStartedAt;
300
+
301
+ stageStartedAt = performance.now();
302
+
303
+ const prepared = yield* prepareImage(options.inputPath);
304
+ const prepareMs = performance.now() - stageStartedAt;
305
+
306
+ stageStartedAt = performance.now();
307
+
308
+ const nativeSession = yield* createSession(modelPath, options.engine);
309
+ const sessionMs = performance.now() - stageStartedAt;
310
+
311
+ stageStartedAt = performance.now();
312
+
313
+ const logits = yield* Effect.acquireUseRelease(
314
+ Effect.succeed(nativeSession.session),
315
+ (session) => runInference(session, prepared.modelInput),
316
+ (session) =>
317
+ Effect.tryPromise({
318
+ try: () => session.release(),
319
+ catch: () => undefined,
320
+ }).pipe(Effect.orElseSucceed(() => undefined)),
321
+ );
322
+
323
+ const inferenceMs = performance.now() - stageStartedAt;
324
+
325
+ stageStartedAt = performance.now();
326
+
327
+ yield* encodeOutput(prepared, logits, options.outputPath, options.format);
328
+
329
+ const encodeMs = performance.now() - stageStartedAt;
330
+ const totalMs = performance.now() - totalStartedAt;
331
+
332
+ return {
333
+ width: prepared.width,
334
+ height: prepared.height,
335
+ outputPath: options.outputPath,
336
+ engine: nativeSession.engine,
337
+ fallbackReason: nativeSession.fallbackReason,
338
+ timings: {
339
+ totalMs,
340
+ modelMs,
341
+ prepareMs,
342
+ sessionMs,
343
+ inferenceMs,
344
+ encodeMs,
345
+ },
346
+ };
347
+ });
@@ -0,0 +1,95 @@
1
+ import { Data, Match } from "effect";
2
+
3
+ export class WebGpuUnavailable extends Data.TaggedError("WebGpuUnavailable")<{
4
+ readonly message: string;
5
+ }> {}
6
+
7
+ export class AdapterUnavailable extends Data.TaggedError("AdapterUnavailable")<{
8
+ readonly message: string;
9
+ }> {}
10
+
11
+ export class DeviceRequestFailed extends Data.TaggedError("DeviceRequestFailed")<{
12
+ readonly message: string;
13
+ }> {}
14
+
15
+ export class RuntimeInitializationFailed extends Data.TaggedError("RuntimeInitializationFailed")<{
16
+ readonly message: string;
17
+ }> {}
18
+
19
+ export class UnsupportedImage extends Data.TaggedError("UnsupportedImage")<{
20
+ readonly mimeType: string;
21
+ }> {}
22
+
23
+ export class ImageDecodeFailed extends Data.TaggedError("ImageDecodeFailed")<{
24
+ readonly fileName: string;
25
+ }> {}
26
+
27
+ export class ImageProcessingFailed extends Data.TaggedError("ImageProcessingFailed")<{
28
+ readonly message: string;
29
+ }> {}
30
+
31
+ export class ModelDownloadFailed extends Data.TaggedError("ModelDownloadFailed")<{
32
+ readonly message: string;
33
+ }> {}
34
+
35
+ export class ModelLoadFailed extends Data.TaggedError("ModelLoadFailed")<{
36
+ readonly message: string;
37
+ }> {}
38
+
39
+ export class InferenceFailed extends Data.TaggedError("InferenceFailed")<{
40
+ readonly message: string;
41
+ }> {}
42
+
43
+ export class ExportFailed extends Data.TaggedError("ExportFailed")<{
44
+ readonly message: string;
45
+ }> {}
46
+
47
+ export type GpuRuntimeError =
48
+ | WebGpuUnavailable
49
+ | AdapterUnavailable
50
+ | DeviceRequestFailed
51
+ | RuntimeInitializationFailed;
52
+
53
+ export type ImageError = UnsupportedImage | ImageDecodeFailed | ImageProcessingFailed;
54
+
55
+ export type BackgroundRemovalError =
56
+ | GpuRuntimeError
57
+ | ImageError
58
+ | ModelDownloadFailed
59
+ | ModelLoadFailed
60
+ | InferenceFailed
61
+ | ExportFailed;
62
+
63
+ export const formatGpuRuntimeError = (error: GpuRuntimeError): string => error.message;
64
+
65
+ export const formatImageError = (error: ImageError): string =>
66
+ Match.value(error).pipe(
67
+ Match.tag("UnsupportedImage", (unsupported) =>
68
+ `Unsupported image type: ${unsupported.mimeType || "unknown"}`
69
+ ),
70
+ Match.tag("ImageDecodeFailed", (decodeFailure) => `Could not decode ${decodeFailure.fileName}.`),
71
+ Match.tag("ImageProcessingFailed", (processingFailure) => processingFailure.message),
72
+ Match.exhaustive,
73
+ );
74
+
75
+ export const formatBackgroundRemovalError = (error: BackgroundRemovalError): string =>
76
+ Match.value(error).pipe(
77
+ Match.tag(
78
+ "UnsupportedImage",
79
+ "ImageDecodeFailed",
80
+ "ImageProcessingFailed",
81
+ (imageError) => formatImageError(imageError),
82
+ ),
83
+ Match.tag(
84
+ "WebGpuUnavailable",
85
+ "AdapterUnavailable",
86
+ "DeviceRequestFailed",
87
+ "RuntimeInitializationFailed",
88
+ "ModelDownloadFailed",
89
+ "ModelLoadFailed",
90
+ "InferenceFailed",
91
+ "ExportFailed",
92
+ (failure) => failure.message,
93
+ ),
94
+ Match.exhaustive,
95
+ );
@@ -0,0 +1,84 @@
1
+ import { Effect } from "effect";
2
+
3
+ import { ImageDecodeFailed, ImageProcessingFailed, UnsupportedImage, type ImageError } from "./errors";
4
+ import { normalizeRgbaToNchw } from "./preprocess";
5
+
6
+ export const MODEL_INPUT_SIZE = 512;
7
+
8
+ const supportedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
9
+
10
+ export const isSupportedImageType = (mimeType: string): boolean => supportedImageTypes.has(mimeType);
11
+
12
+ export type DecodedImage = {
13
+ readonly width: number;
14
+ readonly height: number;
15
+ };
16
+
17
+ export const loadImageBitmap = (file: File): Effect.Effect<ImageBitmap, ImageError> =>
18
+ Effect.gen(function* () {
19
+ if (!isSupportedImageType(file.type)) {
20
+ return yield* new UnsupportedImage({ mimeType: file.type });
21
+ }
22
+
23
+ return yield* Effect.tryPromise({
24
+ try: () => createImageBitmap(file, { imageOrientation: "from-image" }),
25
+ catch: () => new ImageDecodeFailed({ fileName: file.name }),
26
+ });
27
+ });
28
+
29
+ export const decodeImage = (file: File): Effect.Effect<DecodedImage, ImageError> =>
30
+ Effect.acquireUseRelease(
31
+ loadImageBitmap(file),
32
+ (bitmap) => Effect.succeed({ width: bitmap.width, height: bitmap.height }),
33
+ (bitmap) => Effect.sync(() => bitmap.close()),
34
+ );
35
+
36
+ export const prepareModelCanvas = (
37
+ bitmap: ImageBitmap,
38
+ ): Effect.Effect<HTMLCanvasElement, ImageProcessingFailed> =>
39
+ Effect.try({
40
+ try: () => {
41
+ const canvas = document.createElement("canvas");
42
+ canvas.width = MODEL_INPUT_SIZE;
43
+ canvas.height = MODEL_INPUT_SIZE;
44
+
45
+ const context = canvas.getContext("2d");
46
+
47
+ if (context === null) {
48
+ throw new Error("2D canvas is unavailable.");
49
+ }
50
+
51
+ context.imageSmoothingEnabled = true;
52
+ context.imageSmoothingQuality = "high";
53
+ context.drawImage(bitmap, 0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
54
+
55
+ return canvas;
56
+ },
57
+ catch: () =>
58
+ new ImageProcessingFailed({
59
+ message: "The image could not be resized for background-removal inference.",
60
+ }),
61
+ });
62
+
63
+ export const prepareModelInput = (bitmap: ImageBitmap): Effect.Effect<Float32Array, ImageProcessingFailed> =>
64
+ Effect.gen(function* () {
65
+ const canvas = yield* prepareModelCanvas(bitmap);
66
+
67
+ return yield* Effect.try({
68
+ try: () => {
69
+ const context = canvas.getContext("2d", { willReadFrequently: true });
70
+
71
+ if (context === null) {
72
+ throw new Error("2D canvas is unavailable.");
73
+ }
74
+
75
+ const image = context.getImageData(0, 0, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
76
+
77
+ return normalizeRgbaToNchw(image.data, MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
78
+ },
79
+ catch: () =>
80
+ new ImageProcessingFailed({
81
+ message: "The image could not be prepared for background-removal inference.",
82
+ }),
83
+ });
84
+ });
@@ -0,0 +1,2 @@
1
+ export const logitToAlphaByte = (logit: number): number =>
2
+ Math.round(255 / (1 + Math.exp(-logit)));
@@ -0,0 +1,12 @@
1
+ export const MODEL_FILENAME = "birefnet-lite-512-ort-basic-webgpu-v2.onnx";
2
+
3
+ export const MODEL_PUBLIC_PATH = `/models/${MODEL_FILENAME}`;
4
+
5
+ export const MODEL_RELEASE_URL =
6
+ `https://github.com/jhomra21/bgremove/releases/download/model-birefnet-lite-512-ort-basic-webgpu-v2/${MODEL_FILENAME}`;
7
+
8
+ export const MODEL_REVISION = "4a3c40c36c94093cc1e724d9ea428b8fa4b57dc7";
9
+
10
+ export const MODEL_SHA256 = "4461109672dda07a054892aef076b5fcc5fc40bbc91f51a357a7593c7f45ad9c";
11
+
12
+ export const MODEL_SIZE_BYTES = 195_872_736;
@@ -0,0 +1,130 @@
1
+ const imageNetMean = [0.485, 0.456, 0.406] as const;
2
+
3
+ const imageNetStd = [0.229, 0.224, 0.225] as const;
4
+
5
+ type RgbChannel = 0 | 1 | 2;
6
+
7
+ const normalizeChannel = (value: number, channel: RgbChannel): number =>
8
+ (value - imageNetMean[channel]) / imageNetStd[channel];
9
+
10
+ export const normalizeRgbaToNchw = (
11
+ pixels: Uint8Array | Uint8ClampedArray,
12
+ width: number,
13
+ height: number,
14
+ ): Float32Array => {
15
+ const pixelCount = width * height;
16
+ const tensor = new Float32Array(pixelCount * 3);
17
+
18
+ for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
19
+ const rgbaIndex = pixelIndex * 4;
20
+
21
+ tensor[pixelIndex] = normalizeChannel(pixels[rgbaIndex] / 255, 0);
22
+ tensor[pixelCount + pixelIndex] = normalizeChannel(pixels[rgbaIndex + 1] / 255, 1);
23
+ tensor[pixelCount * 2 + pixelIndex] = normalizeChannel(pixels[rgbaIndex + 2] / 255, 2);
24
+ }
25
+
26
+ return tensor;
27
+ };
28
+
29
+ const clampIndex = (value: number, maximum: number): number =>
30
+ Math.min(Math.max(value, 0), maximum);
31
+
32
+ const writeSampledChannel = (
33
+ tensor: Float32Array,
34
+ pixels: Uint8Array | Uint8ClampedArray,
35
+ channel: RgbChannel,
36
+ targetPixelCount: number,
37
+ targetIndex: number,
38
+ topLeftIndex: number,
39
+ topRightIndex: number,
40
+ bottomLeftIndex: number,
41
+ bottomRightIndex: number,
42
+ xMix: number,
43
+ yMix: number,
44
+ ): void => {
45
+ const top = pixels[topLeftIndex + channel] * (1 - xMix)
46
+ + pixels[topRightIndex + channel] * xMix;
47
+
48
+ const bottom = pixels[bottomLeftIndex + channel] * (1 - xMix)
49
+ + pixels[bottomRightIndex + channel] * xMix;
50
+
51
+ const sampled = (top * (1 - yMix) + bottom * yMix) / 255;
52
+
53
+ tensor[channel * targetPixelCount + targetIndex] = normalizeChannel(sampled, channel);
54
+ };
55
+
56
+ export const resizeRgbaLinearToNchw = (
57
+ pixels: Uint8Array | Uint8ClampedArray,
58
+ sourceWidth: number,
59
+ sourceHeight: number,
60
+ targetWidth: number,
61
+ targetHeight: number,
62
+ ): Float32Array => {
63
+ const targetPixelCount = targetWidth * targetHeight;
64
+ const tensor = new Float32Array(targetPixelCount * 3);
65
+ const sourceMaxX = sourceWidth - 1;
66
+ const sourceMaxY = sourceHeight - 1;
67
+
68
+ for (let targetY = 0; targetY < targetHeight; targetY += 1) {
69
+ const sourceY = ((targetY + 0.5) * sourceHeight) / targetHeight - 0.5;
70
+ const sourceYFloor = Math.floor(sourceY);
71
+ const yMix = sourceY - sourceYFloor;
72
+ const y0 = clampIndex(sourceYFloor, sourceMaxY);
73
+ const y1 = clampIndex(sourceYFloor + 1, sourceMaxY);
74
+
75
+ for (let targetX = 0; targetX < targetWidth; targetX += 1) {
76
+ const sourceX = ((targetX + 0.5) * sourceWidth) / targetWidth - 0.5;
77
+ const sourceXFloor = Math.floor(sourceX);
78
+ const xMix = sourceX - sourceXFloor;
79
+ const x0 = clampIndex(sourceXFloor, sourceMaxX);
80
+ const x1 = clampIndex(sourceXFloor + 1, sourceMaxX);
81
+ const topLeftIndex = (y0 * sourceWidth + x0) * 4;
82
+ const topRightIndex = (y0 * sourceWidth + x1) * 4;
83
+ const bottomLeftIndex = (y1 * sourceWidth + x0) * 4;
84
+ const bottomRightIndex = (y1 * sourceWidth + x1) * 4;
85
+ const targetIndex = targetY * targetWidth + targetX;
86
+
87
+ writeSampledChannel(
88
+ tensor,
89
+ pixels,
90
+ 0,
91
+ targetPixelCount,
92
+ targetIndex,
93
+ topLeftIndex,
94
+ topRightIndex,
95
+ bottomLeftIndex,
96
+ bottomRightIndex,
97
+ xMix,
98
+ yMix,
99
+ );
100
+ writeSampledChannel(
101
+ tensor,
102
+ pixels,
103
+ 1,
104
+ targetPixelCount,
105
+ targetIndex,
106
+ topLeftIndex,
107
+ topRightIndex,
108
+ bottomLeftIndex,
109
+ bottomRightIndex,
110
+ xMix,
111
+ yMix,
112
+ );
113
+ writeSampledChannel(
114
+ tensor,
115
+ pixels,
116
+ 2,
117
+ targetPixelCount,
118
+ targetIndex,
119
+ topLeftIndex,
120
+ topRightIndex,
121
+ bottomLeftIndex,
122
+ bottomRightIndex,
123
+ xMix,
124
+ yMix,
125
+ );
126
+ }
127
+ }
128
+
129
+ return tensor;
130
+ };
@@ -0,0 +1,43 @@
1
+ import { Data, Effect } from "effect";
2
+
3
+ export type ModelFileFingerprint = {
4
+ readonly sizeBytes: number;
5
+ readonly sha256: string;
6
+ };
7
+
8
+ export class ModelFileError extends Data.TaggedError("ModelFileError")<{
9
+ readonly path: string;
10
+ readonly cause: unknown;
11
+ }> {}
12
+
13
+ export const inspectModelFile = (
14
+ path: string,
15
+ ): Effect.Effect<ModelFileFingerprint | undefined, ModelFileError> =>
16
+ Effect.tryPromise({
17
+ try: async () => {
18
+ const file = Bun.file(path);
19
+
20
+ if (!(await file.exists())) {
21
+ return undefined;
22
+ }
23
+
24
+ const reader = file.stream().getReader();
25
+ const hasher = new Bun.CryptoHasher("sha256");
26
+
27
+ for (;;) {
28
+ const chunk = await reader.read();
29
+
30
+ if (chunk.done) {
31
+ break;
32
+ }
33
+
34
+ hasher.update(chunk.value);
35
+ }
36
+
37
+ return {
38
+ sizeBytes: file.size,
39
+ sha256: hasher.digest("hex"),
40
+ };
41
+ },
42
+ catch: (cause) => new ModelFileError({ path, cause }),
43
+ });