rn-remove-image-bg 0.0.10
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/NitroRnRemoveImageBg.podspec +33 -0
- package/README.md +386 -0
- package/android/CMakeLists.txt +28 -0
- package/android/build.gradle +142 -0
- package/android/fix-prefab.gradle +51 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/cpp-adapter.cpp +6 -0
- package/android/src/main/java/com/margelo/nitro/rnremoveimagebg/HybridImageBackgroundRemover.kt +189 -0
- package/android/src/main/java/com/margelo/nitro/rnremoveimagebg/NitroRnRemoveImageBgPackage.kt +31 -0
- package/app.plugin.js +12 -0
- package/ios/Bridge.h +8 -0
- package/ios/HybridImageBackgroundRemover.swift +224 -0
- package/ios/NitroRnRemoveImageBgOnLoad.mm +22 -0
- package/lib/ImageProcessing.d.ts +167 -0
- package/lib/ImageProcessing.js +323 -0
- package/lib/ImageProcessing.web.d.ts +80 -0
- package/lib/ImageProcessing.web.js +248 -0
- package/lib/__tests__/cache.test.d.ts +1 -0
- package/lib/__tests__/cache.test.js +87 -0
- package/lib/__tests__/errors.test.d.ts +1 -0
- package/lib/__tests__/errors.test.js +82 -0
- package/lib/cache.d.ts +72 -0
- package/lib/cache.js +228 -0
- package/lib/errors.d.ts +20 -0
- package/lib/errors.js +64 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +9 -0
- package/lib/specs/Example.nitro.d.ts +0 -0
- package/lib/specs/Example.nitro.js +2 -0
- package/lib/specs/ImageBackgroundRemover.nitro.d.ts +41 -0
- package/lib/specs/ImageBackgroundRemover.nitro.js +1 -0
- package/nitro.json +17 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/NitroRnRemoveImageBg+autolinking.cmake +81 -0
- package/nitrogen/generated/android/NitroRnRemoveImageBg+autolinking.gradle +27 -0
- package/nitrogen/generated/android/NitroRnRemoveImageBgOnLoad.cpp +44 -0
- package/nitrogen/generated/android/NitroRnRemoveImageBgOnLoad.hpp +25 -0
- package/nitrogen/generated/android/c++/JHybridImageBackgroundRemoverSpec.cpp +72 -0
- package/nitrogen/generated/android/c++/JHybridImageBackgroundRemoverSpec.hpp +65 -0
- package/nitrogen/generated/android/c++/JNativeRemoveBackgroundOptions.hpp +66 -0
- package/nitrogen/generated/android/c++/JOutputFormat.hpp +59 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/rnremoveimagebg/HybridImageBackgroundRemoverSpec.kt +58 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/rnremoveimagebg/NativeRemoveBackgroundOptions.kt +44 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/rnremoveimagebg/NitroRnRemoveImageBgOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/rnremoveimagebg/OutputFormat.kt +21 -0
- package/nitrogen/generated/ios/NitroRnRemoveImageBg+autolinking.rb +60 -0
- package/nitrogen/generated/ios/NitroRnRemoveImageBg-Swift-Cxx-Bridge.cpp +49 -0
- package/nitrogen/generated/ios/NitroRnRemoveImageBg-Swift-Cxx-Bridge.hpp +111 -0
- package/nitrogen/generated/ios/NitroRnRemoveImageBg-Swift-Cxx-Umbrella.hpp +51 -0
- package/nitrogen/generated/ios/c++/HybridImageBackgroundRemoverSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridImageBackgroundRemoverSpecSwift.hpp +82 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
- package/nitrogen/generated/ios/swift/HybridImageBackgroundRemoverSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridImageBackgroundRemoverSpec_cxx.swift +138 -0
- package/nitrogen/generated/ios/swift/NativeRemoveBackgroundOptions.swift +58 -0
- package/nitrogen/generated/ios/swift/OutputFormat.swift +40 -0
- package/nitrogen/generated/shared/c++/HybridImageBackgroundRemoverSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridImageBackgroundRemoverSpec.hpp +65 -0
- package/nitrogen/generated/shared/c++/NativeRemoveBackgroundOptions.hpp +84 -0
- package/nitrogen/generated/shared/c++/OutputFormat.hpp +76 -0
- package/package.json +104 -0
- package/react-native.config.js +16 -0
- package/src/ImageProcessing.ts +532 -0
- package/src/ImageProcessing.web.ts +342 -0
- package/src/__tests__/ImageProcessing.test.ts +278 -0
- package/src/__tests__/cache.test.ts +110 -0
- package/src/__tests__/errors.test.ts +117 -0
- package/src/cache.ts +305 -0
- package/src/errors.ts +93 -0
- package/src/index.ts +49 -0
- package/src/specs/Example.nitro.ts +1 -0
- package/src/specs/ImageBackgroundRemover.nitro.ts +49 -0
|
@@ -0,0 +1,342 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { removeBackground as imglyRemoveBackground } from '@imgly/background-removal';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Output format for processed images
|
|
12
|
+
*/
|
|
13
|
+
export type OutputFormat = 'PNG' | 'WEBP';
|
|
14
|
+
|
|
15
|
+
export interface CompressImageOptions {
|
|
16
|
+
maxSizeKB?: number;
|
|
17
|
+
width?: number;
|
|
18
|
+
height?: number;
|
|
19
|
+
quality?: number;
|
|
20
|
+
format?: 'webp' | 'png' | 'jpeg';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GenerateThumbhashOptions {
|
|
24
|
+
size?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RemoveBgImageOptions {
|
|
28
|
+
maxDimension?: number;
|
|
29
|
+
format?: OutputFormat;
|
|
30
|
+
quality?: number;
|
|
31
|
+
onProgress?: (progress: number) => void;
|
|
32
|
+
useCache?: boolean;
|
|
33
|
+
debug?: boolean;
|
|
34
|
+
}
|
|
35
|
+
// Web cache configuration
|
|
36
|
+
const webCacheConfig = {
|
|
37
|
+
maxEntries: 50,
|
|
38
|
+
maxAgeMinutes: 30,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Simple in-memory LRU cache for web
|
|
42
|
+
const webCache = new Map<string, string>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Add entry to cache with LRU eviction
|
|
46
|
+
*/
|
|
47
|
+
function setCacheEntry(key: string, value: string): void {
|
|
48
|
+
// If key exists, delete it first (to update LRU order)
|
|
49
|
+
if (webCache.has(key)) {
|
|
50
|
+
webCache.delete(key);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Evict oldest entries if at capacity
|
|
54
|
+
while (webCache.size >= webCacheConfig.maxEntries) {
|
|
55
|
+
const oldestKey = webCache.keys().next().value;
|
|
56
|
+
if (oldestKey) {
|
|
57
|
+
webCache.delete(oldestKey);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
webCache.set(key, value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get entry from cache and update LRU order
|
|
66
|
+
*/
|
|
67
|
+
function getCacheEntry(key: string): string | undefined {
|
|
68
|
+
const value = webCache.get(key);
|
|
69
|
+
if (value !== undefined) {
|
|
70
|
+
// Move to end (most recently used)
|
|
71
|
+
webCache.delete(key);
|
|
72
|
+
webCache.set(key, value);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compress image on web using canvas
|
|
79
|
+
* @returns Compressed image as data URL
|
|
80
|
+
*/
|
|
81
|
+
export async function compressImage(
|
|
82
|
+
uri: string,
|
|
83
|
+
options: CompressImageOptions = {}
|
|
84
|
+
): Promise<string> {
|
|
85
|
+
const {
|
|
86
|
+
maxSizeKB = 250,
|
|
87
|
+
width = 1024,
|
|
88
|
+
height = 1024,
|
|
89
|
+
quality = 0.85,
|
|
90
|
+
format = 'webp',
|
|
91
|
+
} = options;
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// Load image
|
|
95
|
+
const img = await loadImage(uri);
|
|
96
|
+
|
|
97
|
+
// Calculate target dimensions maintaining aspect ratio
|
|
98
|
+
const scale = Math.min(width / img.width, height / img.height, 1);
|
|
99
|
+
const targetWidth = Math.round(img.width * scale);
|
|
100
|
+
const targetHeight = Math.round(img.height * scale);
|
|
101
|
+
|
|
102
|
+
// Create canvas and draw resized image
|
|
103
|
+
const canvas = document.createElement('canvas');
|
|
104
|
+
canvas.width = targetWidth;
|
|
105
|
+
canvas.height = targetHeight;
|
|
106
|
+
const ctx = canvas.getContext('2d');
|
|
107
|
+
if (!ctx) throw new Error('Could not get canvas context');
|
|
108
|
+
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
|
109
|
+
|
|
110
|
+
// Convert to data URL with compression
|
|
111
|
+
const mimeType =
|
|
112
|
+
format === 'png'
|
|
113
|
+
? 'image/png'
|
|
114
|
+
: format === 'jpeg'
|
|
115
|
+
? 'image/jpeg'
|
|
116
|
+
: 'image/webp';
|
|
117
|
+
let dataUrl = canvas.toDataURL(mimeType, quality);
|
|
118
|
+
|
|
119
|
+
// If still too large, reduce quality iteratively
|
|
120
|
+
let currentQuality = quality;
|
|
121
|
+
while (getDataUrlSizeKB(dataUrl) > maxSizeKB && currentQuality > 0.5) {
|
|
122
|
+
currentQuality -= 0.1;
|
|
123
|
+
dataUrl = canvas.toDataURL(mimeType, currentQuality);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return dataUrl;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.warn(
|
|
129
|
+
'[rn-remove-image-bg] compressImage failed on web, returning original:',
|
|
130
|
+
error
|
|
131
|
+
);
|
|
132
|
+
return uri;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Generate thumbhash on web using canvas
|
|
138
|
+
* @returns Base64 thumbhash string
|
|
139
|
+
*/
|
|
140
|
+
export async function generateThumbhash(
|
|
141
|
+
imageUri: string,
|
|
142
|
+
options: GenerateThumbhashOptions = {}
|
|
143
|
+
): Promise<string> {
|
|
144
|
+
const { size = 32 } = options;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// Dynamically import thumbhash to avoid bundling issues
|
|
148
|
+
const { rgbaToThumbHash } = await import('thumbhash');
|
|
149
|
+
|
|
150
|
+
// Load and resize image
|
|
151
|
+
const img = await loadImage(imageUri);
|
|
152
|
+
const canvas = document.createElement('canvas');
|
|
153
|
+
canvas.width = size;
|
|
154
|
+
canvas.height = size;
|
|
155
|
+
const ctx = canvas.getContext('2d');
|
|
156
|
+
if (!ctx) throw new Error('Could not get canvas context');
|
|
157
|
+
ctx.drawImage(img, 0, 0, size, size);
|
|
158
|
+
|
|
159
|
+
// Get RGBA data
|
|
160
|
+
const imageData = ctx.getImageData(0, 0, size, size);
|
|
161
|
+
const hash = rgbaToThumbHash(size, size, imageData.data);
|
|
162
|
+
|
|
163
|
+
// Convert to base64
|
|
164
|
+
return btoa(String.fromCharCode(...hash));
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.warn(
|
|
167
|
+
'[rn-remove-image-bg] generateThumbhash failed on web:',
|
|
168
|
+
error
|
|
169
|
+
);
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Remove background from image on web using @imgly/background-removal
|
|
176
|
+
* @returns Data URL of processed image with transparent background
|
|
177
|
+
*/
|
|
178
|
+
export async function removeBgImage(
|
|
179
|
+
uri: string,
|
|
180
|
+
options: RemoveBgImageOptions = {}
|
|
181
|
+
): Promise<string> {
|
|
182
|
+
const {
|
|
183
|
+
format = 'PNG',
|
|
184
|
+
quality = 100,
|
|
185
|
+
onProgress,
|
|
186
|
+
useCache = true,
|
|
187
|
+
debug = false,
|
|
188
|
+
} = options;
|
|
189
|
+
|
|
190
|
+
// Check cache
|
|
191
|
+
const cacheKey = `${uri}::${format}::${quality}`;
|
|
192
|
+
if (useCache) {
|
|
193
|
+
const cachedResult = getCacheEntry(cacheKey);
|
|
194
|
+
if (cachedResult) {
|
|
195
|
+
if (debug) {
|
|
196
|
+
console.log('[rn-remove-image-bg] Web cache hit');
|
|
197
|
+
}
|
|
198
|
+
onProgress?.(100);
|
|
199
|
+
return cachedResult;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (debug) {
|
|
204
|
+
console.log('[rn-remove-image-bg] Starting web background removal:', uri);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
onProgress?.(5);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// Call @imgly/background-removal
|
|
211
|
+
const blob = await imglyRemoveBackground(uri, {
|
|
212
|
+
progress: (key: string, current: number, total: number) => {
|
|
213
|
+
if (onProgress && total > 0) {
|
|
214
|
+
// Map progress to 10-90 range
|
|
215
|
+
const progress = Math.round(10 + (current / total) * 80);
|
|
216
|
+
onProgress(Math.min(progress, 90));
|
|
217
|
+
}
|
|
218
|
+
if (debug) {
|
|
219
|
+
console.log(`[rn-remove-image-bg] ${key}: ${current}/${total}`);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
output: {
|
|
223
|
+
format: format === 'WEBP' ? 'image/webp' : 'image/png',
|
|
224
|
+
quality: quality / 100,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
onProgress?.(95);
|
|
229
|
+
|
|
230
|
+
// Convert blob to data URL
|
|
231
|
+
const dataUrl = await blobToDataUrl(blob);
|
|
232
|
+
|
|
233
|
+
// Cache the result with LRU eviction
|
|
234
|
+
if (useCache) {
|
|
235
|
+
setCacheEntry(cacheKey, dataUrl);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (debug) {
|
|
239
|
+
console.log('[rn-remove-image-bg] Web background removal complete');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
onProgress?.(100);
|
|
243
|
+
return dataUrl;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.error('[rn-remove-image-bg] Web background removal failed:', error);
|
|
246
|
+
// Return original URI on failure
|
|
247
|
+
onProgress?.(100);
|
|
248
|
+
return uri;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Backward compatibility alias
|
|
254
|
+
* @deprecated Use removeBgImage instead
|
|
255
|
+
*/
|
|
256
|
+
export const removeBackground = removeBgImage;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Clear the web background removal cache
|
|
260
|
+
* @param _deleteFiles - Ignored on web (no disk cache)
|
|
261
|
+
*/
|
|
262
|
+
export async function clearCache(_deleteFiles = false): Promise<void> {
|
|
263
|
+
webCache.clear();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get the current cache size
|
|
268
|
+
*/
|
|
269
|
+
export function getCacheSize(): number {
|
|
270
|
+
return webCache.size;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Handle low memory conditions by clearing the cache
|
|
275
|
+
* On web, this simply clears the in-memory cache
|
|
276
|
+
*
|
|
277
|
+
* @param _deleteFiles - Ignored on web (no disk cache)
|
|
278
|
+
* @returns Number of entries that were cleared
|
|
279
|
+
*/
|
|
280
|
+
export async function onLowMemory(_deleteFiles = true): Promise<number> {
|
|
281
|
+
const size = webCache.size;
|
|
282
|
+
webCache.clear();
|
|
283
|
+
console.log(
|
|
284
|
+
`[rn-remove-image-bg] Cleared ${size} web cache entries due to memory pressure`
|
|
285
|
+
);
|
|
286
|
+
return size;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Configure the background removal cache
|
|
291
|
+
* On web, maxEntries limits cache size. Disk persistence options are no-ops.
|
|
292
|
+
*/
|
|
293
|
+
export function configureCache(config: {
|
|
294
|
+
maxEntries?: number;
|
|
295
|
+
maxAgeMinutes?: number;
|
|
296
|
+
persistToDisk?: boolean;
|
|
297
|
+
cacheDirectory?: string;
|
|
298
|
+
}): void {
|
|
299
|
+
if (config.maxEntries !== undefined && config.maxEntries > 0) {
|
|
300
|
+
webCacheConfig.maxEntries = config.maxEntries;
|
|
301
|
+
}
|
|
302
|
+
if (config.maxAgeMinutes !== undefined && config.maxAgeMinutes > 0) {
|
|
303
|
+
webCacheConfig.maxAgeMinutes = config.maxAgeMinutes;
|
|
304
|
+
}
|
|
305
|
+
// persistToDisk and cacheDirectory are no-ops on web
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Get the cache directory path
|
|
310
|
+
* On web, returns empty string as there is no disk cache
|
|
311
|
+
*/
|
|
312
|
+
export function getCacheDirectory(): string {
|
|
313
|
+
return '';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Helper functions
|
|
317
|
+
|
|
318
|
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
319
|
+
return new Promise((resolve, reject) => {
|
|
320
|
+
const img = new Image();
|
|
321
|
+
img.crossOrigin = 'anonymous';
|
|
322
|
+
img.onload = () => resolve(img);
|
|
323
|
+
img.onerror = reject;
|
|
324
|
+
img.src = src;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function blobToDataUrl(blob: Blob): Promise<string> {
|
|
329
|
+
return new Promise((resolve, reject) => {
|
|
330
|
+
const reader = new FileReader();
|
|
331
|
+
reader.onloadend = () => resolve(reader.result as string);
|
|
332
|
+
reader.onerror = reject;
|
|
333
|
+
reader.readAsDataURL(blob);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getDataUrlSizeKB(dataUrl: string): number {
|
|
338
|
+
// Data URL format: data:mime;base64,<base64data>
|
|
339
|
+
const base64 = dataUrl.split(',')[1] || '';
|
|
340
|
+
// Base64 encodes 3 bytes as 4 characters
|
|
341
|
+
return (base64.length * 3) / 4 / 1024;
|
|
342
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// Mock all dependencies before importing
|
|
4
|
+
vi.mock('react-native', () => ({
|
|
5
|
+
Image: {
|
|
6
|
+
getSize: vi.fn((uri, success) => success(1024, 768)),
|
|
7
|
+
},
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
vi.mock('expo-image-manipulator', () => ({
|
|
11
|
+
manipulateAsync: vi.fn().mockResolvedValue({ uri: 'file:///mock/result.png' }),
|
|
12
|
+
SaveFormat: { WEBP: 'webp', PNG: 'png', JPEG: 'jpeg' },
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
vi.mock('expo-file-system/legacy', () => ({
|
|
16
|
+
cacheDirectory: '/mock/cache/',
|
|
17
|
+
getInfoAsync: vi.fn().mockResolvedValue({ exists: true, size: 1024 }),
|
|
18
|
+
makeDirectoryAsync: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
readAsStringAsync: vi.fn().mockResolvedValue('{}'),
|
|
20
|
+
writeAsStringAsync: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
deleteAsync: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
vi.mock('thumbhash', () => ({
|
|
25
|
+
rgbaToThumbHash: vi.fn().mockReturnValue(new Uint8Array([1, 2, 3, 4])),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
vi.mock('upng-js', () => ({
|
|
29
|
+
decode: vi.fn().mockReturnValue({ width: 32, height: 32 }),
|
|
30
|
+
toRGBA8: vi.fn().mockReturnValue([new Uint8Array(32 * 32 * 4)]),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
// Mock Nitro modules
|
|
34
|
+
const mockRemoveBackground = vi.fn().mockResolvedValue('file:///mock/bg_removed.png')
|
|
35
|
+
vi.mock('react-native-nitro-modules', () => ({
|
|
36
|
+
NitroModules: {
|
|
37
|
+
createHybridObject: vi.fn(() => ({
|
|
38
|
+
removeBackground: mockRemoveBackground,
|
|
39
|
+
})),
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
// Import after mocking
|
|
44
|
+
import {
|
|
45
|
+
removeBgImage,
|
|
46
|
+
clearCache,
|
|
47
|
+
getCacheSize,
|
|
48
|
+
onLowMemory,
|
|
49
|
+
configureCache,
|
|
50
|
+
getCacheDirectory,
|
|
51
|
+
} from '../ImageProcessing'
|
|
52
|
+
import { BackgroundRemovalError } from '../errors'
|
|
53
|
+
|
|
54
|
+
describe('ImageProcessing', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
await clearCache()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('removeBgImage', () => {
|
|
64
|
+
describe('input validation', () => {
|
|
65
|
+
it('should throw INVALID_PATH for empty string', async () => {
|
|
66
|
+
await expect(removeBgImage('')).rejects.toThrow(BackgroundRemovalError)
|
|
67
|
+
await expect(removeBgImage('')).rejects.toMatchObject({
|
|
68
|
+
code: 'INVALID_PATH',
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should throw INVALID_PATH for whitespace-only string', async () => {
|
|
73
|
+
await expect(removeBgImage(' ')).rejects.toMatchObject({
|
|
74
|
+
code: 'INVALID_PATH',
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should throw INVALID_PATH for http URLs', async () => {
|
|
79
|
+
await expect(removeBgImage('http://example.com/image.jpg')).rejects.toMatchObject({
|
|
80
|
+
code: 'INVALID_PATH',
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should throw INVALID_PATH for https URLs', async () => {
|
|
85
|
+
await expect(removeBgImage('https://example.com/image.jpg')).rejects.toMatchObject({
|
|
86
|
+
code: 'INVALID_PATH',
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('should accept file:// URIs', async () => {
|
|
91
|
+
await expect(removeBgImage('file:///path/to/image.jpg')).resolves.toBeDefined()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('should accept absolute paths starting with /', async () => {
|
|
95
|
+
await expect(removeBgImage('/path/to/image.jpg')).resolves.toBeDefined()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('options validation', () => {
|
|
100
|
+
it('should throw INVALID_OPTIONS for maxDimension < 100', async () => {
|
|
101
|
+
await expect(
|
|
102
|
+
removeBgImage('file:///test.jpg', { maxDimension: 50 })
|
|
103
|
+
).rejects.toMatchObject({
|
|
104
|
+
code: 'INVALID_OPTIONS',
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should throw INVALID_OPTIONS for maxDimension > 8192', async () => {
|
|
109
|
+
await expect(
|
|
110
|
+
removeBgImage('file:///test.jpg', { maxDimension: 10000 })
|
|
111
|
+
).rejects.toMatchObject({
|
|
112
|
+
code: 'INVALID_OPTIONS',
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should throw INVALID_OPTIONS for quality < 0', async () => {
|
|
117
|
+
await expect(
|
|
118
|
+
removeBgImage('file:///test.jpg', { quality: -10 })
|
|
119
|
+
).rejects.toMatchObject({
|
|
120
|
+
code: 'INVALID_OPTIONS',
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should throw INVALID_OPTIONS for quality > 100', async () => {
|
|
125
|
+
await expect(
|
|
126
|
+
removeBgImage('file:///test.jpg', { quality: 150 })
|
|
127
|
+
).rejects.toMatchObject({
|
|
128
|
+
code: 'INVALID_OPTIONS',
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should throw INVALID_OPTIONS for invalid format', async () => {
|
|
133
|
+
await expect(
|
|
134
|
+
removeBgImage('file:///test.jpg', { format: 'JPEG' as any })
|
|
135
|
+
).rejects.toMatchObject({
|
|
136
|
+
code: 'INVALID_OPTIONS',
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('should accept valid options', async () => {
|
|
141
|
+
await expect(
|
|
142
|
+
removeBgImage('file:///test.jpg', {
|
|
143
|
+
maxDimension: 1024,
|
|
144
|
+
quality: 90,
|
|
145
|
+
format: 'WEBP',
|
|
146
|
+
})
|
|
147
|
+
).resolves.toBeDefined()
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('progress callback', () => {
|
|
152
|
+
it('should call onProgress during processing', async () => {
|
|
153
|
+
const onProgress = vi.fn()
|
|
154
|
+
await removeBgImage('file:///test.jpg', { onProgress })
|
|
155
|
+
|
|
156
|
+
// Should be called at least for start and end
|
|
157
|
+
expect(onProgress).toHaveBeenCalled()
|
|
158
|
+
expect(onProgress).toHaveBeenCalledWith(expect.any(Number))
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('caching', () => {
|
|
163
|
+
it('should cache results when useCache is true', async () => {
|
|
164
|
+
await removeBgImage('file:///test.jpg', { useCache: true })
|
|
165
|
+
expect(getCacheSize()).toBe(1)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should not cache results when useCache is false', async () => {
|
|
169
|
+
await removeBgImage('file:///test.jpg', { useCache: false })
|
|
170
|
+
expect(getCacheSize()).toBe(0)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('should return cached result on second call', async () => {
|
|
174
|
+
const result1 = await removeBgImage('file:///test.jpg', { useCache: true })
|
|
175
|
+
|
|
176
|
+
// Reset mock to verify it's not called again
|
|
177
|
+
mockRemoveBackground.mockClear()
|
|
178
|
+
|
|
179
|
+
const result2 = await removeBgImage('file:///test.jpg', { useCache: true })
|
|
180
|
+
|
|
181
|
+
expect(result1).toBe(result2)
|
|
182
|
+
// Native should not be called on second request (cache hit)
|
|
183
|
+
expect(mockRemoveBackground).not.toHaveBeenCalled()
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('native call', () => {
|
|
188
|
+
it('should call native removeBackground with correct options', async () => {
|
|
189
|
+
await removeBgImage('file:///test.jpg', {
|
|
190
|
+
maxDimension: 1024,
|
|
191
|
+
format: 'WEBP',
|
|
192
|
+
quality: 85,
|
|
193
|
+
useCache: false, // Don't cache so we can verify the call
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
expect(mockRemoveBackground).toHaveBeenCalledWith(
|
|
197
|
+
'file:///test.jpg',
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
maxDimension: 1024,
|
|
200
|
+
format: 'WEBP',
|
|
201
|
+
quality: 85,
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('should normalize result path to file:// URI', async () => {
|
|
207
|
+
mockRemoveBackground.mockResolvedValueOnce('/path/without/scheme.png')
|
|
208
|
+
|
|
209
|
+
const result = await removeBgImage('file:///test.jpg', { useCache: false })
|
|
210
|
+
|
|
211
|
+
expect(result).toBe('file:///path/without/scheme.png')
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('cache management functions', () => {
|
|
217
|
+
describe('clearCache', () => {
|
|
218
|
+
it('should clear all cache entries', async () => {
|
|
219
|
+
await removeBgImage('file:///test1.jpg', { useCache: true })
|
|
220
|
+
await removeBgImage('file:///test2.jpg', { useCache: true })
|
|
221
|
+
expect(getCacheSize()).toBe(2)
|
|
222
|
+
|
|
223
|
+
await clearCache()
|
|
224
|
+
expect(getCacheSize()).toBe(0)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('getCacheSize', () => {
|
|
229
|
+
it('should return 0 for empty cache', () => {
|
|
230
|
+
expect(getCacheSize()).toBe(0)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('should return correct count after adding entries', async () => {
|
|
234
|
+
await removeBgImage('file:///test1.jpg', { useCache: true })
|
|
235
|
+
expect(getCacheSize()).toBe(1)
|
|
236
|
+
|
|
237
|
+
await removeBgImage('file:///test2.jpg', { useCache: true })
|
|
238
|
+
expect(getCacheSize()).toBe(2)
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
describe('onLowMemory', () => {
|
|
243
|
+
it('should clear cache and return count', async () => {
|
|
244
|
+
await removeBgImage('file:///test1.jpg', { useCache: true })
|
|
245
|
+
await removeBgImage('file:///test2.jpg', { useCache: true })
|
|
246
|
+
|
|
247
|
+
const cleared = await onLowMemory()
|
|
248
|
+
|
|
249
|
+
expect(cleared).toBe(2)
|
|
250
|
+
expect(getCacheSize()).toBe(0)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('should return 0 when cache is empty', async () => {
|
|
254
|
+
const cleared = await onLowMemory()
|
|
255
|
+
expect(cleared).toBe(0)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('configureCache', () => {
|
|
260
|
+
it('should not throw when configuring cache', () => {
|
|
261
|
+
expect(() =>
|
|
262
|
+
configureCache({
|
|
263
|
+
maxEntries: 100,
|
|
264
|
+
maxAgeMinutes: 60,
|
|
265
|
+
persistToDisk: true,
|
|
266
|
+
})
|
|
267
|
+
).not.toThrow()
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('getCacheDirectory', () => {
|
|
272
|
+
it('should return a string containing bg-removal', () => {
|
|
273
|
+
const dir = getCacheDirectory()
|
|
274
|
+
expect(dir).toContain('bg-removal')
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
})
|