rn-remove-image-bg 0.0.12 → 0.0.14

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 CHANGED
@@ -15,28 +15,8 @@
15
15
 
16
16
  ## Installation
17
17
 
18
- Install from GitHub:
19
-
20
18
  ```bash
21
- # Using npm
22
- npm install github:a-eid/rn-remove-image-bg react-native-nitro-modules
23
-
24
- # Using yarn
25
- yarn add github:a-eid/rn-remove-image-bg react-native-nitro-modules
26
-
27
- # Using pnpm
28
- pnpm add github:a-eid/rn-remove-image-bg react-native-nitro-modules
29
- ```
30
-
31
- Or add to your `package.json`:
32
-
33
- ```json
34
- {
35
- "dependencies": {
36
- "rn-remove-image-bg": "github:a-eid/rn-remove-image-bg",
37
- "react-native-nitro-modules": "0.31.10"
38
- }
39
- }
19
+ npm install rn-remove-image-bg react-native-nitro-modules
40
20
  ```
41
21
 
42
22
  ### Peer Dependencies
@@ -59,18 +39,86 @@ cd ios && pod install
59
39
 
60
40
  No additional setup required. The ML Kit model (~10MB) downloads automatically on first use.
61
41
 
42
+ > **Important:** Android requires Google Play Services. The first call may take 10-15 seconds while the model downloads.
43
+
62
44
  ---
63
45
 
64
46
  ## Quick Start
65
47
 
48
+ ### Basic Usage
49
+
66
50
  ```typescript
67
51
  import { removeBgImage } from 'rn-remove-image-bg'
68
52
 
69
- // Remove background from an image
70
53
  const resultUri = await removeBgImage('file:///path/to/photo.jpg')
71
- console.log(resultUri) // file:///path/to/cache/bg_removed_xxx.png
54
+ // Returns: file:///path/to/cache/bg_removed_xxx.png (native)
55
+ // Returns: data:image/png;base64,... (web)
56
+ ```
57
+
58
+ ### With React Query (Recommended)
59
+
60
+ ```typescript
61
+ import { useMutation } from '@tanstack/react-query'
62
+ import { removeBgImage } from 'rn-remove-image-bg'
63
+ import { Alert } from 'react-native'
64
+
65
+ function useRemoveBackground() {
66
+ return useMutation({
67
+ mutationFn: async (imageUri: string) => {
68
+ return await removeBgImage(imageUri, {
69
+ maxDimension: 1024, // Faster processing
70
+ format: 'PNG', // Best for transparency
71
+ useCache: true, // Cache results
72
+ })
73
+ },
74
+ onError: (error) => {
75
+ Alert.alert('Error', error.message)
76
+ },
77
+ })
78
+ }
79
+
80
+ // In your component:
81
+ function ImageEditor() {
82
+ const removeBackground = useRemoveBackground()
83
+
84
+ const handleRemoveBackground = () => {
85
+ removeBackground.mutate(selectedImageUri, {
86
+ onSuccess: (resultUri) => {
87
+ setProcessedImage(resultUri)
88
+ },
89
+ })
90
+ }
91
+
92
+ return (
93
+ <Button
94
+ onPress={handleRemoveBackground}
95
+ disabled={removeBackground.isPending}
96
+ title={removeBackground.isPending ? 'Processing...' : 'Remove Background'}
97
+ />
98
+ )
99
+ }
72
100
  ```
73
101
 
102
+ ### With Expo Image Picker
103
+
104
+ ```typescript
105
+ import * as ImagePicker from 'expo-image-picker'
106
+ import { removeBgImage } from 'rn-remove-image-bg'
107
+
108
+ async function pickAndProcessImage() {
109
+ // Pick image
110
+ const result = await ImagePicker.launchImageLibraryAsync({
111
+ mediaTypes: ImagePicker.MediaTypeOptions.Images,
112
+ quality: 1,
113
+ })
114
+
115
+ if (result.canceled) return null
116
+
117
+ // Remove background
118
+ const processedUri = await removeBgImage(result.assets[0].uri)
119
+ return processedUri
120
+ }
121
+
74
122
  ---
75
123
 
76
124
  ## API Reference
@@ -361,9 +409,39 @@ If you don't need web support, you can tree-shake the `@imgly/background-removal
361
409
 
362
410
  ### Android Model Not Loading?
363
411
 
364
- 1. Ensure device has Google Play Services
365
- 2. Check internet connection for first download
366
- 3. Clear app cache and retry
412
+ 1. Ensure device has Google Play Services installed
413
+ 2. Check internet connection (model downloads on first use)
414
+ 3. Wait 10-15 seconds - the library automatically retries during download
415
+ 4. Clear app cache and retry: `adb shell pm clear com.yourapp`
416
+
417
+ ### Web Not Working?
418
+
419
+ 1. Check browser console for errors
420
+ 2. Ensure your bundler supports WebAssembly
421
+ 3. The first call downloads a ~35MB WASM model
422
+ 4. CORS may block model download - check network tab
423
+
424
+ ### Native Module Not Found?
425
+
426
+ ```bash
427
+ # iOS
428
+ cd ios && pod install && cd ..
429
+ npx expo run:ios --device
430
+
431
+ # Android
432
+ npx expo run:android --device
433
+
434
+ # For Expo managed workflow
435
+ npx expo prebuild --clean
436
+ ```
437
+
438
+ ### TypeScript Errors?
439
+
440
+ Ensure peer dependencies match:
441
+ ```bash
442
+ npx expo install expo-file-system expo-image-manipulator
443
+ npm install react-native-nitro-modules@0.31.10
444
+ ```
367
445
 
368
446
  ---
369
447
 
@@ -1,13 +1,20 @@
1
1
  /**
2
- * Web implementation using @imgly/background-removal
3
- *
4
- * Provides real background removal on web using WebAssembly and ML models.
5
- * Falls back to no-op if the library fails to load.
2
+ * Web implementation using @huggingface/transformers
3
+ * Uses BRIAAI RMBG-1.4 model for background removal.
4
+ * Loads from CDN to bypass Metro bundler issues.
6
5
  */
6
+ export type OutputFormat = 'PNG' | 'WEBP';
7
+ export interface RemoveBgImageOptions {
8
+ format?: OutputFormat;
9
+ quality?: number;
10
+ onProgress?: (progress: number) => void;
11
+ debug?: boolean;
12
+ }
7
13
  /**
8
- * Output format for processed images
14
+ * Remove background from image
9
15
  */
10
- export type OutputFormat = 'PNG' | 'WEBP';
16
+ export declare function removeBgImage(uri: string, options?: RemoveBgImageOptions): Promise<string>;
17
+ export declare const removeBackground: typeof removeBgImage;
11
18
  export interface CompressImageOptions {
12
19
  maxSizeKB?: number;
13
20
  width?: number;
@@ -18,63 +25,12 @@ export interface CompressImageOptions {
18
25
  export interface GenerateThumbhashOptions {
19
26
  size?: number;
20
27
  }
21
- export interface RemoveBgImageOptions {
22
- maxDimension?: number;
23
- format?: OutputFormat;
24
- quality?: number;
25
- onProgress?: (progress: number) => void;
26
- useCache?: boolean;
27
- debug?: boolean;
28
- }
29
- /**
30
- * Compress image on web using canvas
31
- * @returns Compressed image as data URL
32
- */
33
28
  export declare function compressImage(uri: string, options?: CompressImageOptions): Promise<string>;
34
- /**
35
- * Generate thumbhash on web using canvas
36
- * @returns Base64 thumbhash string
37
- */
38
29
  export declare function generateThumbhash(imageUri: string, options?: GenerateThumbhashOptions): Promise<string>;
39
- /**
40
- * Remove background from image on web using @imgly/background-removal
41
- * @returns Data URL of processed image with transparent background
42
- */
43
- export declare function removeBgImage(uri: string, options?: RemoveBgImageOptions): Promise<string>;
44
- /**
45
- * Backward compatibility alias
46
- * @deprecated Use removeBgImage instead
47
- */
48
- export declare const removeBackground: typeof removeBgImage;
49
- /**
50
- * Clear the web background removal cache
51
- * @param _deleteFiles - Ignored on web (no disk cache)
52
- */
53
- export declare function clearCache(_deleteFiles?: boolean): Promise<void>;
54
- /**
55
- * Get the current cache size
56
- */
30
+ export declare function clearCache(): Promise<void>;
57
31
  export declare function getCacheSize(): number;
58
- /**
59
- * Handle low memory conditions by clearing the cache
60
- * On web, this simply clears the in-memory cache
61
- *
62
- * @param _deleteFiles - Ignored on web (no disk cache)
63
- * @returns Number of entries that were cleared
64
- */
65
- export declare function onLowMemory(_deleteFiles?: boolean): Promise<number>;
66
- /**
67
- * Configure the background removal cache
68
- * On web, maxEntries limits cache size. Disk persistence options are no-ops.
69
- */
70
- export declare function configureCache(config: {
32
+ export declare function onLowMemory(): Promise<number>;
33
+ export declare function configureCache(_config: {
71
34
  maxEntries?: number;
72
- maxAgeMinutes?: number;
73
- persistToDisk?: boolean;
74
- cacheDirectory?: string;
75
35
  }): void;
76
- /**
77
- * Get the cache directory path
78
- * On web, returns empty string as there is no disk cache
79
- */
80
36
  export declare function getCacheDirectory(): string;
@@ -1,59 +1,102 @@
1
1
  /**
2
- * Web implementation using @imgly/background-removal
3
- *
4
- * Provides real background removal on web using WebAssembly and ML models.
5
- * Falls back to no-op if the library fails to load.
2
+ * Web implementation using @huggingface/transformers
3
+ * Uses BRIAAI RMBG-1.4 model for background removal.
4
+ * Loads from CDN to bypass Metro bundler issues.
6
5
  */
7
- // Web cache configuration
8
- const webCacheConfig = {
9
- maxEntries: 50,
10
- maxAgeMinutes: 30,
11
- };
12
- // Simple in-memory LRU cache for web
13
- const webCache = new Map();
14
- /**
15
- * Add entry to cache with LRU eviction
16
- */
17
- function setCacheEntry(key, value) {
18
- // If key exists, delete it first (to update LRU order)
19
- if (webCache.has(key)) {
20
- webCache.delete(key);
21
- }
22
- // Evict oldest entries if at capacity
23
- while (webCache.size >= webCacheConfig.maxEntries) {
24
- const oldestKey = webCache.keys().next().value;
25
- if (oldestKey) {
26
- webCache.delete(oldestKey);
27
- }
28
- }
29
- webCache.set(key, value);
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ let pipeline = null;
8
+ let loadPromise = null;
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ let transformersModule = null;
11
+ // Load transformers.js from CDN to avoid Metro bundling issues
12
+ async function loadTransformers() {
13
+ if (transformersModule)
14
+ return transformersModule;
15
+ // Use dynamic import from CDN
16
+ const cdnUrl = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3';
17
+ transformersModule = await import(/* webpackIgnore: true */ cdnUrl);
18
+ return transformersModule;
30
19
  }
31
- /**
32
- * Get entry from cache and update LRU order
33
- */
34
- function getCacheEntry(key) {
35
- const value = webCache.get(key);
36
- if (value !== undefined) {
37
- // Move to end (most recently used)
38
- webCache.delete(key);
39
- webCache.set(key, value);
20
+ async function ensureLoaded(onProgress, debug) {
21
+ if (pipeline)
22
+ return pipeline;
23
+ if (loadPromise) {
24
+ await loadPromise;
25
+ return pipeline;
40
26
  }
41
- return value;
27
+ loadPromise = (async () => {
28
+ if (debug)
29
+ console.log('[rmbg] Loading model...');
30
+ const { pipeline: createPipeline, env } = await loadTransformers();
31
+ env.allowLocalModels = false;
32
+ env.useBrowserCache = true;
33
+ onProgress?.(10);
34
+ pipeline = await createPipeline('image-segmentation', 'briaai/RMBG-1.4', {
35
+ dtype: 'q8',
36
+ progress_callback: (info) => {
37
+ if (onProgress && 'progress' in info && typeof info.progress === 'number') {
38
+ onProgress(Math.min(10 + (info.progress / 100) * 50, 60));
39
+ }
40
+ },
41
+ });
42
+ if (debug)
43
+ console.log('[rmbg] Model ready');
44
+ })();
45
+ await loadPromise;
46
+ return pipeline;
42
47
  }
43
48
  /**
44
- * Compress image on web using canvas
45
- * @returns Compressed image as data URL
49
+ * Remove background from image
46
50
  */
51
+ export async function removeBgImage(uri, options = {}) {
52
+ const { format = 'PNG', quality = 100, onProgress, debug = false } = options;
53
+ if (debug)
54
+ console.log('[rmbg] Processing:', uri);
55
+ onProgress?.(5);
56
+ const segmenter = await ensureLoaded(onProgress, debug);
57
+ onProgress?.(60);
58
+ const results = await segmenter(uri);
59
+ onProgress?.(90);
60
+ const result = Array.isArray(results) ? results[0] : results;
61
+ if (!result?.mask)
62
+ throw new Error('No mask returned');
63
+ // Convert to data URL
64
+ const dataUrl = typeof result.mask === 'string'
65
+ ? result.mask
66
+ : toDataUrl(result.mask, format, quality);
67
+ if (debug)
68
+ console.log('[rmbg] Done');
69
+ onProgress?.(100);
70
+ return dataUrl;
71
+ }
72
+ function toDataUrl(mask, format, quality) {
73
+ const canvas = document.createElement('canvas');
74
+ canvas.width = mask.width;
75
+ canvas.height = mask.height;
76
+ const ctx = canvas.getContext('2d');
77
+ const imageData = ctx.createImageData(mask.width, mask.height);
78
+ imageData.data.set(mask.data);
79
+ ctx.putImageData(imageData, 0, 0);
80
+ return canvas.toDataURL(format === 'WEBP' ? 'image/webp' : 'image/png', quality / 100);
81
+ }
82
+ export const removeBackground = removeBgImage;
83
+ // Helper to load image
84
+ function loadImage(src) {
85
+ return new Promise((resolve, reject) => {
86
+ const img = new Image();
87
+ img.crossOrigin = 'anonymous';
88
+ img.onload = () => resolve(img);
89
+ img.onerror = reject;
90
+ img.src = src;
91
+ });
92
+ }
47
93
  export async function compressImage(uri, options = {}) {
48
94
  const { maxSizeKB = 250, width = 1024, height = 1024, quality = 0.85, format = 'webp', } = options;
49
95
  try {
50
- // Load image
51
96
  const img = await loadImage(uri);
52
- // Calculate target dimensions maintaining aspect ratio
53
97
  const scale = Math.min(width / img.width, height / img.height, 1);
54
98
  const targetWidth = Math.round(img.width * scale);
55
99
  const targetHeight = Math.round(img.height * scale);
56
- // Create canvas and draw resized image
57
100
  const canvas = document.createElement('canvas');
58
101
  canvas.width = targetWidth;
59
102
  canvas.height = targetHeight;
@@ -61,36 +104,25 @@ export async function compressImage(uri, options = {}) {
61
104
  if (!ctx)
62
105
  throw new Error('Could not get canvas context');
63
106
  ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
64
- // Convert to data URL with compression
65
- const mimeType = format === 'png'
66
- ? 'image/png'
67
- : format === 'jpeg'
68
- ? 'image/jpeg'
69
- : 'image/webp';
107
+ const mimeType = format === 'png' ? 'image/png' : format === 'jpeg' ? 'image/jpeg' : 'image/webp';
70
108
  let dataUrl = canvas.toDataURL(mimeType, quality);
71
- // If still too large, reduce quality iteratively
109
+ // Reduce quality if over size limit
72
110
  let currentQuality = quality;
73
- while (getDataUrlSizeKB(dataUrl) > maxSizeKB && currentQuality > 0.5) {
111
+ const getSize = (url) => ((url.split(',')[1] || '').length * 3) / 4 / 1024;
112
+ while (getSize(dataUrl) > maxSizeKB && currentQuality > 0.5) {
74
113
  currentQuality -= 0.1;
75
114
  dataUrl = canvas.toDataURL(mimeType, currentQuality);
76
115
  }
77
116
  return dataUrl;
78
117
  }
79
118
  catch (error) {
80
- console.warn('[rn-remove-image-bg] compressImage failed on web, returning original:', error);
119
+ console.warn('[rmbg] compressImage failed:', error);
81
120
  return uri;
82
121
  }
83
122
  }
84
- /**
85
- * Generate thumbhash on web using canvas
86
- * @returns Base64 thumbhash string
87
- */
88
123
  export async function generateThumbhash(imageUri, options = {}) {
89
124
  const { size = 32 } = options;
90
125
  try {
91
- // Dynamically import thumbhash to avoid bundling issues
92
- const { rgbaToThumbHash } = await import('thumbhash');
93
- // Load and resize image
94
126
  const img = await loadImage(imageUri);
95
127
  const canvas = document.createElement('canvas');
96
128
  canvas.width = size;
@@ -99,151 +131,28 @@ export async function generateThumbhash(imageUri, options = {}) {
99
131
  if (!ctx)
100
132
  throw new Error('Could not get canvas context');
101
133
  ctx.drawImage(img, 0, 0, size, size);
102
- // Get RGBA data
103
134
  const imageData = ctx.getImageData(0, 0, size, size);
135
+ // Load thumbhash from CDN
136
+ // @ts-expect-error CDN import works at runtime
137
+ const { rgbaToThumbHash } = await import(/* webpackIgnore: true */ 'https://cdn.jsdelivr.net/npm/thumbhash@0.1/+esm');
104
138
  const hash = rgbaToThumbHash(size, size, imageData.data);
105
- // Convert to base64
106
139
  return btoa(String.fromCharCode(...hash));
107
140
  }
108
141
  catch (error) {
109
- console.warn('[rn-remove-image-bg] generateThumbhash failed on web:', error);
142
+ console.warn('[rmbg] generateThumbhash failed:', error);
110
143
  return '';
111
144
  }
112
145
  }
113
- /**
114
- * Remove background from image on web using @imgly/background-removal
115
- * @returns Data URL of processed image with transparent background
116
- */
117
- export async function removeBgImage(uri, options = {}) {
118
- const { format = 'PNG', quality = 100, onProgress, useCache = true, debug = false, } = options;
119
- // Check cache
120
- const cacheKey = `${uri}::${format}::${quality}`;
121
- if (useCache) {
122
- const cachedResult = getCacheEntry(cacheKey);
123
- if (cachedResult) {
124
- if (debug) {
125
- console.log('[rn-remove-image-bg] Web cache hit');
126
- }
127
- onProgress?.(100);
128
- return cachedResult;
129
- }
130
- }
131
- if (debug) {
132
- console.log('[rn-remove-image-bg] Starting web background removal:', uri);
133
- }
134
- onProgress?.(5);
135
- try {
136
- // Dynamically import the library to prevent Metro from parsing onnxruntime-web at build time
137
- const { removeBackground: imglyRemoveBackground } = await import('@imgly/background-removal');
138
- // Call @imgly/background-removal
139
- const blob = await imglyRemoveBackground(uri, {
140
- progress: (key, current, total) => {
141
- if (onProgress && total > 0) {
142
- // Map progress to 10-90 range
143
- const progress = Math.round(10 + (current / total) * 80);
144
- onProgress(Math.min(progress, 90));
145
- }
146
- if (debug) {
147
- console.log(`[rn-remove-image-bg] ${key}: ${current}/${total}`);
148
- }
149
- },
150
- output: {
151
- format: format === 'WEBP' ? 'image/webp' : 'image/png',
152
- quality: quality / 100,
153
- },
154
- });
155
- onProgress?.(95);
156
- // Convert blob to data URL
157
- const dataUrl = await blobToDataUrl(blob);
158
- // Cache the result with LRU eviction
159
- if (useCache) {
160
- setCacheEntry(cacheKey, dataUrl);
161
- }
162
- if (debug) {
163
- console.log('[rn-remove-image-bg] Web background removal complete');
164
- }
165
- onProgress?.(100);
166
- return dataUrl;
167
- }
168
- catch (error) {
169
- console.error('[rn-remove-image-bg] Web background removal failed:', error);
170
- // Throw error instead of silent failure
171
- const message = error instanceof Error ? error.message : 'Web background removal failed';
172
- throw new Error(`Background removal failed: ${message}`);
173
- }
174
- }
175
- /**
176
- * Backward compatibility alias
177
- * @deprecated Use removeBgImage instead
178
- */
179
- export const removeBackground = removeBgImage;
180
- /**
181
- * Clear the web background removal cache
182
- * @param _deleteFiles - Ignored on web (no disk cache)
183
- */
184
- export async function clearCache(_deleteFiles = false) {
185
- webCache.clear();
146
+ export async function clearCache() {
147
+ pipeline = null;
148
+ loadPromise = null;
186
149
  }
187
- /**
188
- * Get the current cache size
189
- */
190
150
  export function getCacheSize() {
191
- return webCache.size;
192
- }
193
- /**
194
- * Handle low memory conditions by clearing the cache
195
- * On web, this simply clears the in-memory cache
196
- *
197
- * @param _deleteFiles - Ignored on web (no disk cache)
198
- * @returns Number of entries that were cleared
199
- */
200
- export async function onLowMemory(_deleteFiles = true) {
201
- const size = webCache.size;
202
- webCache.clear();
203
- console.log(`[rn-remove-image-bg] Cleared ${size} web cache entries due to memory pressure`);
204
- return size;
205
- }
206
- /**
207
- * Configure the background removal cache
208
- * On web, maxEntries limits cache size. Disk persistence options are no-ops.
209
- */
210
- export function configureCache(config) {
211
- if (config.maxEntries !== undefined && config.maxEntries > 0) {
212
- webCacheConfig.maxEntries = config.maxEntries;
213
- }
214
- if (config.maxAgeMinutes !== undefined && config.maxAgeMinutes > 0) {
215
- webCacheConfig.maxAgeMinutes = config.maxAgeMinutes;
216
- }
217
- // persistToDisk and cacheDirectory are no-ops on web
218
- }
219
- /**
220
- * Get the cache directory path
221
- * On web, returns empty string as there is no disk cache
222
- */
223
- export function getCacheDirectory() {
224
- return '';
225
- }
226
- // Helper functions
227
- function loadImage(src) {
228
- return new Promise((resolve, reject) => {
229
- const img = new Image();
230
- img.crossOrigin = 'anonymous';
231
- img.onload = () => resolve(img);
232
- img.onerror = reject;
233
- img.src = src;
234
- });
235
- }
236
- function blobToDataUrl(blob) {
237
- return new Promise((resolve, reject) => {
238
- const reader = new FileReader();
239
- reader.onloadend = () => resolve(reader.result);
240
- reader.onerror = reject;
241
- reader.readAsDataURL(blob);
242
- });
151
+ return 0;
243
152
  }
244
- function getDataUrlSizeKB(dataUrl) {
245
- // Data URL format: data:mime;base64,<base64data>
246
- const base64 = dataUrl.split(',')[1] || '';
247
- // Base64 encodes 3 bytes as 4 characters
248
- return (base64.length * 3) / 4 / 1024;
153
+ export async function onLowMemory() {
154
+ await clearCache();
155
+ return 0;
249
156
  }
157
+ export function configureCache(_config) { }
158
+ export function getCacheDirectory() { return ''; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rn-remove-image-bg",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "rn-remove-image-bg",
5
5
  "homepage": "https://github.com/a-eid/rn-remove-image-bg",
6
6
  "main": "lib/index",
@@ -35,17 +35,6 @@
35
35
  "*.podspec",
36
36
  "README.md"
37
37
  ],
38
- "scripts": {
39
- "postinstall": "tsc || exit 0;",
40
- "typecheck": "tsc --noEmit",
41
- "clean": "rm -rf android/build node_modules/**/android/build lib",
42
- "lint": "eslint \"**/*.{js,ts,tsx}\" --fix",
43
- "lint-ci": "eslint \"**/*.{js,ts,tsx}\" -f @jamesacarr/github-actions",
44
- "typescript": "tsc",
45
- "specs": "tsc --noEmit false && nitrogen --logLevel=\"debug\"",
46
- "prepare": "npm run typescript",
47
- "test": "vitest run"
48
- },
49
38
  "author": "Ahmed Eid <a.eid@yandex.com> (https://github.com/a-eid)",
50
39
  "license": "MIT",
51
40
  "devDependencies": {
@@ -96,9 +85,19 @@
96
85
  "lib/"
97
86
  ],
98
87
  "dependencies": {
99
- "@imgly/background-removal": "^1.7.0",
88
+ "@huggingface/transformers": "^3.8.1",
100
89
  "buffer": "^6.0.3",
101
90
  "thumbhash": "^0.1.1",
102
91
  "upng-js": "^2.1.0"
92
+ },
93
+ "scripts": {
94
+ "postinstall": "tsc || exit 0;",
95
+ "typecheck": "tsc --noEmit",
96
+ "clean": "rm -rf android/build node_modules/**/android/build lib",
97
+ "lint": "eslint \"**/*.{js,ts,tsx}\" --fix",
98
+ "lint-ci": "eslint \"**/*.{js,ts,tsx}\" -f @jamesacarr/github-actions",
99
+ "typescript": "tsc",
100
+ "specs": "tsc --noEmit false && nitrogen --logLevel=\"debug\"",
101
+ "test": "vitest run"
103
102
  }
104
- }
103
+ }
@@ -1,84 +1,140 @@
1
1
  /**
2
- * Web implementation using @imgly/background-removal
3
- *
4
- * Provides real background removal on web using WebAssembly and ML models.
5
- * Falls back to no-op if the library fails to load.
2
+ * Web implementation using @huggingface/transformers
3
+ * Uses BRIAAI RMBG-1.4 model for background removal.
4
+ * Loads from CDN to bypass Metro bundler issues.
6
5
  */
7
6
 
8
- // @imgly/background-removal is dynamically imported at runtime in removeBgImage()
9
- // to prevent Metro from parsing onnxruntime-web at build time
10
-
11
- /**
12
- * Output format for processed images
13
- */
14
7
  export type OutputFormat = 'PNG' | 'WEBP';
15
8
 
16
- export interface CompressImageOptions {
17
- maxSizeKB?: number;
18
- width?: number;
19
- height?: number;
20
- quality?: number;
21
- format?: 'webp' | 'png' | 'jpeg';
22
- }
23
-
24
- export interface GenerateThumbhashOptions {
25
- size?: number;
26
- }
27
-
28
9
  export interface RemoveBgImageOptions {
29
- maxDimension?: number;
30
10
  format?: OutputFormat;
31
11
  quality?: number;
32
12
  onProgress?: (progress: number) => void;
33
- useCache?: boolean;
34
13
  debug?: boolean;
35
14
  }
36
- // Web cache configuration
37
- const webCacheConfig = {
38
- maxEntries: 50,
39
- maxAgeMinutes: 30,
40
- };
41
-
42
- // Simple in-memory LRU cache for web
43
- const webCache = new Map<string, string>();
44
15
 
45
- /**
46
- * Add entry to cache with LRU eviction
47
- */
48
- function setCacheEntry(key: string, value: string): void {
49
- // If key exists, delete it first (to update LRU order)
50
- if (webCache.has(key)) {
51
- webCache.delete(key);
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ let pipeline: any = null;
18
+ let loadPromise: Promise<void> | null = null;
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ let transformersModule: any = null;
21
+
22
+ // Load transformers.js from CDN to avoid Metro bundling issues
23
+ async function loadTransformers() {
24
+ if (transformersModule) return transformersModule;
25
+
26
+ // Use dynamic import from CDN
27
+ const cdnUrl = 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3';
28
+ transformersModule = await import(/* webpackIgnore: true */ cdnUrl);
29
+ return transformersModule;
30
+ }
31
+
32
+ async function ensureLoaded(onProgress?: (p: number) => void, debug?: boolean) {
33
+ if (pipeline) return pipeline;
34
+
35
+ if (loadPromise) {
36
+ await loadPromise;
37
+ return pipeline;
52
38
  }
53
39
 
54
- // Evict oldest entries if at capacity
55
- while (webCache.size >= webCacheConfig.maxEntries) {
56
- const oldestKey = webCache.keys().next().value;
57
- if (oldestKey) {
58
- webCache.delete(oldestKey);
59
- }
60
- }
40
+ loadPromise = (async () => {
41
+ if (debug) console.log('[rmbg] Loading model...');
42
+
43
+ const { pipeline: createPipeline, env } = await loadTransformers();
44
+
45
+ env.allowLocalModels = false;
46
+ env.useBrowserCache = true;
47
+
48
+ onProgress?.(10);
49
+
50
+ pipeline = await createPipeline('image-segmentation', 'briaai/RMBG-1.4', {
51
+ dtype: 'q8',
52
+ progress_callback: (info: { progress?: number }) => {
53
+ if (onProgress && 'progress' in info && typeof info.progress === 'number') {
54
+ onProgress(Math.min(10 + (info.progress / 100) * 50, 60));
55
+ }
56
+ },
57
+ });
58
+
59
+ if (debug) console.log('[rmbg] Model ready');
60
+ })();
61
61
 
62
- webCache.set(key, value);
62
+ await loadPromise;
63
+ return pipeline;
63
64
  }
64
65
 
65
66
  /**
66
- * Get entry from cache and update LRU order
67
+ * Remove background from image
67
68
  */
68
- function getCacheEntry(key: string): string | undefined {
69
- const value = webCache.get(key);
70
- if (value !== undefined) {
71
- // Move to end (most recently used)
72
- webCache.delete(key);
73
- webCache.set(key, value);
74
- }
75
- return value;
69
+ export async function removeBgImage(
70
+ uri: string,
71
+ options: RemoveBgImageOptions = {}
72
+ ): Promise<string> {
73
+ const { format = 'PNG', quality = 100, onProgress, debug = false } = options;
74
+
75
+ if (debug) console.log('[rmbg] Processing:', uri);
76
+ onProgress?.(5);
77
+
78
+ const segmenter = await ensureLoaded(onProgress, debug);
79
+ onProgress?.(60);
80
+
81
+ const results = await segmenter(uri);
82
+ onProgress?.(90);
83
+
84
+ const result = Array.isArray(results) ? results[0] : results;
85
+ if (!result?.mask) throw new Error('No mask returned');
86
+
87
+ // Convert to data URL
88
+ const dataUrl = typeof result.mask === 'string'
89
+ ? result.mask
90
+ : toDataUrl(result.mask, format, quality);
91
+
92
+ if (debug) console.log('[rmbg] Done');
93
+ onProgress?.(100);
94
+
95
+ return dataUrl;
96
+ }
97
+
98
+ function toDataUrl(
99
+ mask: { width: number; height: number; data: Uint8Array | Uint8ClampedArray },
100
+ format: OutputFormat,
101
+ quality: number
102
+ ): string {
103
+ const canvas = document.createElement('canvas');
104
+ canvas.width = mask.width;
105
+ canvas.height = mask.height;
106
+ const ctx = canvas.getContext('2d')!;
107
+ const imageData = ctx.createImageData(mask.width, mask.height);
108
+ imageData.data.set(mask.data);
109
+ ctx.putImageData(imageData, 0, 0);
110
+ return canvas.toDataURL(format === 'WEBP' ? 'image/webp' : 'image/png', quality / 100);
111
+ }
112
+
113
+ export const removeBackground = removeBgImage;
114
+
115
+ // Helper to load image
116
+ function loadImage(src: string): Promise<HTMLImageElement> {
117
+ return new Promise((resolve, reject) => {
118
+ const img = new Image();
119
+ img.crossOrigin = 'anonymous';
120
+ img.onload = () => resolve(img);
121
+ img.onerror = reject;
122
+ img.src = src;
123
+ });
124
+ }
125
+
126
+ export interface CompressImageOptions {
127
+ maxSizeKB?: number;
128
+ width?: number;
129
+ height?: number;
130
+ quality?: number;
131
+ format?: 'webp' | 'png' | 'jpeg';
132
+ }
133
+
134
+ export interface GenerateThumbhashOptions {
135
+ size?: number;
76
136
  }
77
137
 
78
- /**
79
- * Compress image on web using canvas
80
- * @returns Compressed image as data URL
81
- */
82
138
  export async function compressImage(
83
139
  uri: string,
84
140
  options: CompressImageOptions = {}
@@ -92,15 +148,11 @@ export async function compressImage(
92
148
  } = options;
93
149
 
94
150
  try {
95
- // Load image
96
151
  const img = await loadImage(uri);
97
-
98
- // Calculate target dimensions maintaining aspect ratio
99
152
  const scale = Math.min(width / img.width, height / img.height, 1);
100
153
  const targetWidth = Math.round(img.width * scale);
101
154
  const targetHeight = Math.round(img.height * scale);
102
155
 
103
- // Create canvas and draw resized image
104
156
  const canvas = document.createElement('canvas');
105
157
  canvas.width = targetWidth;
106
158
  canvas.height = targetHeight;
@@ -108,36 +160,24 @@ export async function compressImage(
108
160
  if (!ctx) throw new Error('Could not get canvas context');
109
161
  ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
110
162
 
111
- // Convert to data URL with compression
112
- const mimeType =
113
- format === 'png'
114
- ? 'image/png'
115
- : format === 'jpeg'
116
- ? 'image/jpeg'
117
- : 'image/webp';
163
+ const mimeType = format === 'png' ? 'image/png' : format === 'jpeg' ? 'image/jpeg' : 'image/webp';
118
164
  let dataUrl = canvas.toDataURL(mimeType, quality);
119
165
 
120
- // If still too large, reduce quality iteratively
166
+ // Reduce quality if over size limit
121
167
  let currentQuality = quality;
122
- while (getDataUrlSizeKB(dataUrl) > maxSizeKB && currentQuality > 0.5) {
168
+ const getSize = (url: string) => ((url.split(',')[1] || '').length * 3) / 4 / 1024;
169
+ while (getSize(dataUrl) > maxSizeKB && currentQuality > 0.5) {
123
170
  currentQuality -= 0.1;
124
171
  dataUrl = canvas.toDataURL(mimeType, currentQuality);
125
172
  }
126
173
 
127
174
  return dataUrl;
128
175
  } catch (error) {
129
- console.warn(
130
- '[rn-remove-image-bg] compressImage failed on web, returning original:',
131
- error
132
- );
176
+ console.warn('[rmbg] compressImage failed:', error);
133
177
  return uri;
134
178
  }
135
179
  }
136
180
 
137
- /**
138
- * Generate thumbhash on web using canvas
139
- * @returns Base64 thumbhash string
140
- */
141
181
  export async function generateThumbhash(
142
182
  imageUri: string,
143
183
  options: GenerateThumbhashOptions = {}
@@ -145,11 +185,8 @@ export async function generateThumbhash(
145
185
  const { size = 32 } = options;
146
186
 
147
187
  try {
148
- // Dynamically import thumbhash to avoid bundling issues
149
- const { rgbaToThumbHash } = await import('thumbhash');
150
-
151
- // Load and resize image
152
188
  const img = await loadImage(imageUri);
189
+
153
190
  const canvas = document.createElement('canvas');
154
191
  canvas.width = size;
155
192
  canvas.height = size;
@@ -157,191 +194,32 @@ export async function generateThumbhash(
157
194
  if (!ctx) throw new Error('Could not get canvas context');
158
195
  ctx.drawImage(img, 0, 0, size, size);
159
196
 
160
- // Get RGBA data
161
197
  const imageData = ctx.getImageData(0, 0, size, size);
198
+
199
+ // Load thumbhash from CDN
200
+ // @ts-expect-error CDN import works at runtime
201
+ const { rgbaToThumbHash } = await import(/* webpackIgnore: true */ 'https://cdn.jsdelivr.net/npm/thumbhash@0.1/+esm');
162
202
  const hash = rgbaToThumbHash(size, size, imageData.data);
163
-
164
- // Convert to base64
165
203
  return btoa(String.fromCharCode(...hash));
166
204
  } catch (error) {
167
- console.warn(
168
- '[rn-remove-image-bg] generateThumbhash failed on web:',
169
- error
170
- );
205
+ console.warn('[rmbg] generateThumbhash failed:', error);
171
206
  return '';
172
207
  }
173
208
  }
174
209
 
175
- /**
176
- * Remove background from image on web using @imgly/background-removal
177
- * @returns Data URL of processed image with transparent background
178
- */
179
- export async function removeBgImage(
180
- uri: string,
181
- options: RemoveBgImageOptions = {}
182
- ): Promise<string> {
183
- const {
184
- format = 'PNG',
185
- quality = 100,
186
- onProgress,
187
- useCache = true,
188
- debug = false,
189
- } = options;
190
-
191
- // Check cache
192
- const cacheKey = `${uri}::${format}::${quality}`;
193
- if (useCache) {
194
- const cachedResult = getCacheEntry(cacheKey);
195
- if (cachedResult) {
196
- if (debug) {
197
- console.log('[rn-remove-image-bg] Web cache hit');
198
- }
199
- onProgress?.(100);
200
- return cachedResult;
201
- }
202
- }
203
-
204
- if (debug) {
205
- console.log('[rn-remove-image-bg] Starting web background removal:', uri);
206
- }
207
-
208
- onProgress?.(5);
209
-
210
- try {
211
- // Dynamically import the library to prevent Metro from parsing onnxruntime-web at build time
212
- const { removeBackground: imglyRemoveBackground } =
213
- await import('@imgly/background-removal');
214
-
215
- // Call @imgly/background-removal
216
- const blob = await imglyRemoveBackground(uri, {
217
- progress: (key: string, current: number, total: number) => {
218
- if (onProgress && total > 0) {
219
- // Map progress to 10-90 range
220
- const progress = Math.round(10 + (current / total) * 80);
221
- onProgress(Math.min(progress, 90));
222
- }
223
- if (debug) {
224
- console.log(`[rn-remove-image-bg] ${key}: ${current}/${total}`);
225
- }
226
- },
227
- output: {
228
- format: format === 'WEBP' ? 'image/webp' : 'image/png',
229
- quality: quality / 100,
230
- },
231
- });
232
-
233
- onProgress?.(95);
234
-
235
- // Convert blob to data URL
236
- const dataUrl = await blobToDataUrl(blob);
237
-
238
- // Cache the result with LRU eviction
239
- if (useCache) {
240
- setCacheEntry(cacheKey, dataUrl);
241
- }
242
-
243
- if (debug) {
244
- console.log('[rn-remove-image-bg] Web background removal complete');
245
- }
246
-
247
- onProgress?.(100);
248
- return dataUrl;
249
- } catch (error) {
250
- console.error('[rn-remove-image-bg] Web background removal failed:', error);
251
- // Throw error instead of silent failure
252
- const message = error instanceof Error ? error.message : 'Web background removal failed';
253
- throw new Error(`Background removal failed: ${message}`);
254
- }
255
- }
256
-
257
- /**
258
- * Backward compatibility alias
259
- * @deprecated Use removeBgImage instead
260
- */
261
- export const removeBackground = removeBgImage;
262
-
263
- /**
264
- * Clear the web background removal cache
265
- * @param _deleteFiles - Ignored on web (no disk cache)
266
- */
267
- export async function clearCache(_deleteFiles = false): Promise<void> {
268
- webCache.clear();
210
+ export async function clearCache(): Promise<void> {
211
+ pipeline = null;
212
+ loadPromise = null;
269
213
  }
270
214
 
271
- /**
272
- * Get the current cache size
273
- */
274
215
  export function getCacheSize(): number {
275
- return webCache.size;
276
- }
277
-
278
- /**
279
- * Handle low memory conditions by clearing the cache
280
- * On web, this simply clears the in-memory cache
281
- *
282
- * @param _deleteFiles - Ignored on web (no disk cache)
283
- * @returns Number of entries that were cleared
284
- */
285
- export async function onLowMemory(_deleteFiles = true): Promise<number> {
286
- const size = webCache.size;
287
- webCache.clear();
288
- console.log(
289
- `[rn-remove-image-bg] Cleared ${size} web cache entries due to memory pressure`
290
- );
291
- return size;
292
- }
293
-
294
- /**
295
- * Configure the background removal cache
296
- * On web, maxEntries limits cache size. Disk persistence options are no-ops.
297
- */
298
- export function configureCache(config: {
299
- maxEntries?: number;
300
- maxAgeMinutes?: number;
301
- persistToDisk?: boolean;
302
- cacheDirectory?: string;
303
- }): void {
304
- if (config.maxEntries !== undefined && config.maxEntries > 0) {
305
- webCacheConfig.maxEntries = config.maxEntries;
306
- }
307
- if (config.maxAgeMinutes !== undefined && config.maxAgeMinutes > 0) {
308
- webCacheConfig.maxAgeMinutes = config.maxAgeMinutes;
309
- }
310
- // persistToDisk and cacheDirectory are no-ops on web
311
- }
312
-
313
- /**
314
- * Get the cache directory path
315
- * On web, returns empty string as there is no disk cache
316
- */
317
- export function getCacheDirectory(): string {
318
- return '';
216
+ return 0;
319
217
  }
320
218
 
321
- // Helper functions
322
-
323
- function loadImage(src: string): Promise<HTMLImageElement> {
324
- return new Promise((resolve, reject) => {
325
- const img = new Image();
326
- img.crossOrigin = 'anonymous';
327
- img.onload = () => resolve(img);
328
- img.onerror = reject;
329
- img.src = src;
330
- });
331
- }
332
-
333
- function blobToDataUrl(blob: Blob): Promise<string> {
334
- return new Promise((resolve, reject) => {
335
- const reader = new FileReader();
336
- reader.onloadend = () => resolve(reader.result as string);
337
- reader.onerror = reject;
338
- reader.readAsDataURL(blob);
339
- });
219
+ export async function onLowMemory(): Promise<number> {
220
+ await clearCache();
221
+ return 0;
340
222
  }
341
223
 
342
- function getDataUrlSizeKB(dataUrl: string): number {
343
- // Data URL format: data:mime;base64,<base64data>
344
- const base64 = dataUrl.split(',')[1] || '';
345
- // Base64 encodes 3 bytes as 4 characters
346
- return (base64.length * 3) / 4 / 1024;
347
- }
224
+ export function configureCache(_config: { maxEntries?: number }): void {}
225
+ export function getCacheDirectory(): string { return ''; }