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.
Files changed (74) hide show
  1. package/NitroRnRemoveImageBg.podspec +33 -0
  2. package/README.md +386 -0
  3. package/android/CMakeLists.txt +28 -0
  4. package/android/build.gradle +142 -0
  5. package/android/fix-prefab.gradle +51 -0
  6. package/android/gradle.properties +5 -0
  7. package/android/src/main/AndroidManifest.xml +3 -0
  8. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  9. package/android/src/main/java/com/margelo/nitro/rnremoveimagebg/HybridImageBackgroundRemover.kt +189 -0
  10. package/android/src/main/java/com/margelo/nitro/rnremoveimagebg/NitroRnRemoveImageBgPackage.kt +31 -0
  11. package/app.plugin.js +12 -0
  12. package/ios/Bridge.h +8 -0
  13. package/ios/HybridImageBackgroundRemover.swift +224 -0
  14. package/ios/NitroRnRemoveImageBgOnLoad.mm +22 -0
  15. package/lib/ImageProcessing.d.ts +167 -0
  16. package/lib/ImageProcessing.js +323 -0
  17. package/lib/ImageProcessing.web.d.ts +80 -0
  18. package/lib/ImageProcessing.web.js +248 -0
  19. package/lib/__tests__/cache.test.d.ts +1 -0
  20. package/lib/__tests__/cache.test.js +87 -0
  21. package/lib/__tests__/errors.test.d.ts +1 -0
  22. package/lib/__tests__/errors.test.js +82 -0
  23. package/lib/cache.d.ts +72 -0
  24. package/lib/cache.js +228 -0
  25. package/lib/errors.d.ts +20 -0
  26. package/lib/errors.js +64 -0
  27. package/lib/index.d.ts +6 -0
  28. package/lib/index.js +9 -0
  29. package/lib/specs/Example.nitro.d.ts +0 -0
  30. package/lib/specs/Example.nitro.js +2 -0
  31. package/lib/specs/ImageBackgroundRemover.nitro.d.ts +41 -0
  32. package/lib/specs/ImageBackgroundRemover.nitro.js +1 -0
  33. package/nitro.json +17 -0
  34. package/nitrogen/generated/.gitattributes +1 -0
  35. package/nitrogen/generated/android/NitroRnRemoveImageBg+autolinking.cmake +81 -0
  36. package/nitrogen/generated/android/NitroRnRemoveImageBg+autolinking.gradle +27 -0
  37. package/nitrogen/generated/android/NitroRnRemoveImageBgOnLoad.cpp +44 -0
  38. package/nitrogen/generated/android/NitroRnRemoveImageBgOnLoad.hpp +25 -0
  39. package/nitrogen/generated/android/c++/JHybridImageBackgroundRemoverSpec.cpp +72 -0
  40. package/nitrogen/generated/android/c++/JHybridImageBackgroundRemoverSpec.hpp +65 -0
  41. package/nitrogen/generated/android/c++/JNativeRemoveBackgroundOptions.hpp +66 -0
  42. package/nitrogen/generated/android/c++/JOutputFormat.hpp +59 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/rnremoveimagebg/HybridImageBackgroundRemoverSpec.kt +58 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/rnremoveimagebg/NativeRemoveBackgroundOptions.kt +44 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/rnremoveimagebg/NitroRnRemoveImageBgOnLoad.kt +35 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/rnremoveimagebg/OutputFormat.kt +21 -0
  47. package/nitrogen/generated/ios/NitroRnRemoveImageBg+autolinking.rb +60 -0
  48. package/nitrogen/generated/ios/NitroRnRemoveImageBg-Swift-Cxx-Bridge.cpp +49 -0
  49. package/nitrogen/generated/ios/NitroRnRemoveImageBg-Swift-Cxx-Bridge.hpp +111 -0
  50. package/nitrogen/generated/ios/NitroRnRemoveImageBg-Swift-Cxx-Umbrella.hpp +51 -0
  51. package/nitrogen/generated/ios/c++/HybridImageBackgroundRemoverSpecSwift.cpp +11 -0
  52. package/nitrogen/generated/ios/c++/HybridImageBackgroundRemoverSpecSwift.hpp +82 -0
  53. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +47 -0
  54. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
  55. package/nitrogen/generated/ios/swift/HybridImageBackgroundRemoverSpec.swift +56 -0
  56. package/nitrogen/generated/ios/swift/HybridImageBackgroundRemoverSpec_cxx.swift +138 -0
  57. package/nitrogen/generated/ios/swift/NativeRemoveBackgroundOptions.swift +58 -0
  58. package/nitrogen/generated/ios/swift/OutputFormat.swift +40 -0
  59. package/nitrogen/generated/shared/c++/HybridImageBackgroundRemoverSpec.cpp +21 -0
  60. package/nitrogen/generated/shared/c++/HybridImageBackgroundRemoverSpec.hpp +65 -0
  61. package/nitrogen/generated/shared/c++/NativeRemoveBackgroundOptions.hpp +84 -0
  62. package/nitrogen/generated/shared/c++/OutputFormat.hpp +76 -0
  63. package/package.json +104 -0
  64. package/react-native.config.js +16 -0
  65. package/src/ImageProcessing.ts +532 -0
  66. package/src/ImageProcessing.web.ts +342 -0
  67. package/src/__tests__/ImageProcessing.test.ts +278 -0
  68. package/src/__tests__/cache.test.ts +110 -0
  69. package/src/__tests__/errors.test.ts +117 -0
  70. package/src/cache.ts +305 -0
  71. package/src/errors.ts +93 -0
  72. package/src/index.ts +49 -0
  73. package/src/specs/Example.nitro.ts +1 -0
  74. 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
+ })