@nitida/asset-compressor-native 0.2.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,92 @@
1
+ # @nitida/asset-compressor-native
2
+
3
+ Fast, native (iOS + Android) image compression for React Native apps that
4
+ upload to the [aquienpz](https://aquienpz.com) asset platform. Pairs with
5
+ `@aquienpz/asset-uploader-expo` and mirrors the API of
6
+ `@nitida/asset-compressor-web` so call sites are identical across
7
+ platforms.
8
+
9
+ ## Why this exists
10
+
11
+ `expo-image-manipulator` is the default option in Expo apps, but it
12
+ runs the resize + re-encode through the JS bridge and ships an
13
+ intermediate bitmap as a JS-heap allocation. On a 12MP iPhone HEIC, a
14
+ single resize-to-2880px + JPEG q=0.85 takes ~2–4 seconds — long enough
15
+ to make a 10-photo batch upload feel broken.
16
+
17
+ This package wraps [`react-native-compressor`](https://github.com/numandev1/react-native-compressor),
18
+ which calls `libjpeg-turbo` (iOS) / `Bitmap.compress` (Android) on a
19
+ background thread and **never moves pixel data across the bridge**. The
20
+ same operation runs in ~250–350ms — roughly **5–10× faster** than the
21
+ Expo equivalent on identical hardware.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ # in your Expo / RN app
27
+ npm install @nitida/asset-compressor-native react-native-compressor
28
+ # Expo: also run the config-plugin once
29
+ npx expo prebuild
30
+ ```
31
+
32
+ Add the plugin entry to `app.json`:
33
+
34
+ ```json
35
+ {
36
+ "expo": {
37
+ "plugins": ["react-native-compressor"]
38
+ }
39
+ }
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```ts
45
+ import { compressImage, compressImages } from "@nitida/asset-compressor-native";
46
+
47
+ // Single image
48
+ const result = await compressImage({
49
+ uri: pickerAsset.uri,
50
+ filename: pickerAsset.fileName ?? "photo.jpg",
51
+ });
52
+ // → { uri: 'file:///.../photo.jpg', size: 412903, originalSize: 4218876, compressionRatio: 0.097 }
53
+
54
+ // Batch
55
+ const { successful, failed } = await compressImages(
56
+ pickerAssets.map((a, i) => ({ uri: a.uri, filename: a.fileName ?? `photo-${i}.jpg`, id: a.assetId })),
57
+ { quality: 0.85, maxWidth: 2880, concurrency: 1 },
58
+ (id, status, index) => console.log(`[${index}] ${id} → ${status}`),
59
+ );
60
+ ```
61
+
62
+ ## Output format
63
+
64
+ Output is **always JPEG**. `react-native-compressor` natively supports
65
+ only `jpg` / `png`, and the asset-manager re-encodes every uploaded
66
+ image to WebP server-side anyway when generating size variants — so
67
+ forcing WebP on device would cost binary size + native module
68
+ maintenance for zero net bandwidth win.
69
+
70
+ The on-device savings (HEIC/big-JPEG → 2880px JPEG q=0.85) are already
71
+ ~80–95% before the upload starts.
72
+
73
+ ## API parity with `@nitida/asset-compressor-web`
74
+
75
+ | Web | Native |
76
+ | ---------------------------------------- | ----------------------------------------------- |
77
+ | input `Blob` | input `uri` (file://, content://, ph://) |
78
+ | output `Blob` (mime configurable) | output file `uri` on disk (always JPEG) |
79
+ | `compressionStatus: convertingHeic` | not emitted (HEIC decode is part of `compressing`) |
80
+ | concurrency: `p-limit` / `navigator.hwc` | concurrency: bounded worker pool, default 1 |
81
+ | HEIC fallback: `heic2any` | HEIC handled natively by ImageIO / android-heif |
82
+
83
+ Everything else (quality default 0.85, maxWidth/Height 2880, status
84
+ callback shape, error collection in `compressImages`) is identical.
85
+
86
+ ## Cleanup
87
+
88
+ `react-native-compressor` writes outputs to the app's cache directory.
89
+ iOS will reclaim it under memory pressure; Android does the same when
90
+ the cache exceeds `cacheQuotaBytes`. If you upload + don't need the
91
+ local copy, delete it yourself with `expo-file-system` or
92
+ `react-native-fs` once the upload completes.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Public types for @nitida/asset-compressor-native.
3
+ *
4
+ * Mirrors @nitida/asset-compressor-web's surface so a single SDK call
5
+ * (`aq.upload(file, { compress: true })`) works identically across web
6
+ * and React Native. The only platform-specific divergence: RN inputs are
7
+ * file `uri` strings (e.g. `file:///...`, `content://...`, `ph://...`)
8
+ * rather than browser `Blob`s, since `Blob` on Hermes is a thin wrapper
9
+ * that can't be read efficiently and react-native-compressor operates on
10
+ * URIs natively.
11
+ */
12
+ type CompressionStatusKey = "convertingHeic" | "compressing" | "compressingKeepingDimensions" | "skipped" | "done";
13
+ /**
14
+ * Subset of react-native-compressor's options we expose. Output format
15
+ * intentionally omitted from the public surface: we always emit JPEG on
16
+ * device (see `constants.ts` rationale). HEIC inputs are auto-decoded
17
+ * by iOS ImageIO and Android's BitmapFactory inside the native module,
18
+ * so no separate `convertingHeic` step is needed — react-native-compressor
19
+ * handles it transparently.
20
+ */
21
+ type CompressionOptions = {
22
+ /** 0..1, defaults to 0.85 — matches web compressor */
23
+ quality?: number;
24
+ /** Pixels; defaults to 2880 (longest side cap) */
25
+ maxWidth?: number;
26
+ /** Pixels; defaults to 2880 (longest side cap) */
27
+ maxHeight?: number;
28
+ /** If true, ignore maxWidth/Height and keep source dimensions. */
29
+ keepOriginalDimensions?: boolean;
30
+ /** Caller escape hatch — when true, compression is bypassed entirely. */
31
+ skipCompression?: boolean;
32
+ };
33
+ /**
34
+ * Result of a single compression. `uri` points to the compressed file on
35
+ * the device filesystem; the caller is responsible for uploading it and
36
+ * cleaning up (react-native-compressor writes to the app cache dir,
37
+ * which iOS/Android will reclaim under memory pressure).
38
+ */
39
+ type CompressedResult = {
40
+ /** `file://...` path to the compressed JPEG on disk. */
41
+ uri: string;
42
+ filename: string;
43
+ /** Bytes of the compressed output. */
44
+ size: number;
45
+ /** Mime type of the output — always `"image/jpeg"` for now. */
46
+ mimeType: "image/jpeg";
47
+ /** Original (pre-compression) size in bytes. */
48
+ originalSize: number;
49
+ /** size / originalSize. <1 = saved bytes. */
50
+ compressionRatio: number;
51
+ /** Caller-supplied stable id, echoed back. */
52
+ id?: string;
53
+ originalArrayIndex: number;
54
+ };
55
+ type CompressionError = {
56
+ filename: string;
57
+ originalUri: string;
58
+ error: Error;
59
+ id?: string;
60
+ originalArrayIndex: number;
61
+ };
62
+
63
+ /**
64
+ * Native image compression via react-native-compressor.
65
+ *
66
+ * Why react-native-compressor instead of expo-image-manipulator:
67
+ *
68
+ * - iOS: uses CGImageDestination + libjpeg-turbo natively. No JS-bridge
69
+ * round trip per pixel, no JSON serialization of pixel arrays.
70
+ * - Android: uses BitmapFactory + Bitmap.compress() on a background
71
+ * thread, with downsampling at the decode step (inSampleSize) so a
72
+ * 12MP source never fully decodes if the target is 2880px.
73
+ * - HEIC auto-decodes on both platforms (CoreImage / android-heif).
74
+ *
75
+ * In practice, on a 2019 iPhone XR, a 12MP HEIC (~4MB) → 2880px JPEG
76
+ * q=0.85 takes ~250-350ms. The same operation via
77
+ * expo-image-manipulator takes 2-4 seconds (JS bridge + intermediate
78
+ * bitmap allocation in the JS heap). The 10× delta is what the user
79
+ * was hitting in apps/asset-lab-mobile.
80
+ */
81
+
82
+ type CompressInput = {
83
+ /** `file://...`, `content://...`, `ph://...`, or remote URL. */
84
+ uri: string;
85
+ /** Display name. Used in result + error reports. */
86
+ filename: string;
87
+ /** Caller-supplied stable id; passed back unchanged. */
88
+ id?: string;
89
+ };
90
+ /**
91
+ * Compress a single image. Designed to be called directly when you only
92
+ * have one file; otherwise use `compressImages` for batched throughput
93
+ * control.
94
+ */
95
+ declare function compressImage(opts: {
96
+ uri: string;
97
+ filename: string;
98
+ options?: CompressionOptions;
99
+ originalArrayIndex?: number;
100
+ id?: string;
101
+ statusCallback?: (status: CompressionStatusKey) => void;
102
+ }): Promise<CompressedResult>;
103
+ /**
104
+ * Compress many images with bounded concurrency. Failures are collected
105
+ * — they don't reject the overall promise, so one bad file can't sink a
106
+ * whole batch.
107
+ *
108
+ * Default concurrency = 1 (sequential). Bump to 2 on flagship devices if
109
+ * profiling shows headroom; going higher tends to thermally throttle.
110
+ */
111
+ declare function compressImages(images: CompressInput[], options?: CompressionOptions & {
112
+ concurrency?: number;
113
+ }, statusCallback?: (id: string | undefined, status: CompressionStatusKey, index: number) => void): Promise<{
114
+ successful: CompressedResult[];
115
+ failed: CompressionError[];
116
+ }>;
117
+
118
+ /**
119
+ * Defaults for the native compressor. Values intentionally match
120
+ * @nitida/asset-compressor-web so a single SDK call produces
121
+ * predictable upload sizes across platforms.
122
+ *
123
+ * Why JPEG (not WebP) on device:
124
+ *
125
+ * react-native-compressor's native modules only expose `output: 'jpg'
126
+ * | 'png'`. Adding WebP would require shipping libwebp on iOS (Apple
127
+ * pulled native WebP encoding from CoreImage) and binding it via a
128
+ * custom Turbo Module. The marginal bandwidth win (~25% smaller than
129
+ * JPEG q=0.85) is not worth the binary-size + maintenance cost
130
+ * because the asset-manager re-encodes EVERY uploaded image to WebP
131
+ * server-side anyway when generating size variants. So:
132
+ *
133
+ * - Device → JPEG q=0.85, 2880px max side (libjpeg-turbo native,
134
+ * ~5–10× faster than expo-image-manipulator's WebP path)
135
+ * - Server → WebP variants at `s|m|l|x` presets + on-the-fly
136
+ * transforms
137
+ *
138
+ * Net result: the user uploads ~80% smaller bytes than a raw
139
+ * 12MP iPhone HEIC/JPEG, the server re-encodes once, every CDN
140
+ * variant is WebP. Win-win-win.
141
+ */
142
+
143
+ /** Maximum dimension (longest side) of the compressed output. */
144
+ declare const MAX_UPLOAD_DIMENSION = 2880;
145
+ declare const DEFAULT_COMPRESSION_OPTIONS: Required<Omit<CompressionOptions, "keepOriginalDimensions" | "skipCompression">>;
146
+ /**
147
+ * Sequential by default. Mobile devices have thermal + memory constraints
148
+ * that make parallel compression a net loss above 2 concurrent encodes
149
+ * (the GPU/CPU sharing model on phones doesn't scale like a laptop). The
150
+ * caller can override via `compressImages(..., { concurrency: 2 })`.
151
+ */
152
+ declare const DEFAULT_NATIVE_CONCURRENCY = 1;
153
+
154
+ export { type CompressInput, type CompressedResult, type CompressionError, type CompressionOptions, type CompressionStatusKey, DEFAULT_COMPRESSION_OPTIONS, DEFAULT_NATIVE_CONCURRENCY, MAX_UPLOAD_DIMENSION, compressImage, compressImages };
package/dist/index.js ADDED
@@ -0,0 +1,113 @@
1
+ // src/compression.ts
2
+ import { getImageMetaData, Image as RNCImage } from "react-native-compressor";
3
+
4
+ // src/constants.ts
5
+ var STANDARD_QUALITY = 0.85;
6
+ var MAX_UPLOAD_DIMENSION = 2880;
7
+ var DEFAULT_COMPRESSION_OPTIONS = {
8
+ quality: STANDARD_QUALITY,
9
+ maxWidth: MAX_UPLOAD_DIMENSION,
10
+ maxHeight: MAX_UPLOAD_DIMENSION
11
+ };
12
+ var DEFAULT_NATIVE_CONCURRENCY = 1;
13
+
14
+ // src/compression.ts
15
+ async function getSizeBytes(uri) {
16
+ try {
17
+ const meta = await getImageMetaData(uri);
18
+ return typeof meta.size === "number" ? meta.size : 0;
19
+ } catch {
20
+ return 0;
21
+ }
22
+ }
23
+ async function compressImage(opts) {
24
+ const opt = { ...DEFAULT_COMPRESSION_OPTIONS, ...opts.options };
25
+ if (opts.options?.skipCompression) {
26
+ const size = await getSizeBytes(opts.uri);
27
+ opts.statusCallback?.("skipped");
28
+ return {
29
+ uri: opts.uri,
30
+ filename: opts.filename,
31
+ size,
32
+ mimeType: "image/jpeg",
33
+ originalSize: size,
34
+ compressionRatio: 1,
35
+ id: opts.id,
36
+ originalArrayIndex: opts.originalArrayIndex ?? 0
37
+ };
38
+ }
39
+ const originalSize = await getSizeBytes(opts.uri);
40
+ opts.statusCallback?.(
41
+ opts.options?.keepOriginalDimensions ? "compressingKeepingDimensions" : "compressing"
42
+ );
43
+ const compressedUri = await RNCImage.compress(opts.uri, {
44
+ compressionMethod: "manual",
45
+ quality: opt.quality,
46
+ maxWidth: opts.options?.keepOriginalDimensions ? Number.POSITIVE_INFINITY : opt.maxWidth,
47
+ maxHeight: opts.options?.keepOriginalDimensions ? Number.POSITIVE_INFINITY : opt.maxHeight,
48
+ output: "jpg",
49
+ returnableOutputType: "uri"
50
+ });
51
+ const compressedSize = await getSizeBytes(compressedUri);
52
+ opts.statusCallback?.("done");
53
+ return {
54
+ uri: compressedUri,
55
+ filename: opts.filename.replace(/\.(heic|heif|png|webp)$/i, ".jpg"),
56
+ size: compressedSize,
57
+ mimeType: "image/jpeg",
58
+ originalSize,
59
+ compressionRatio: originalSize > 0 ? compressedSize / originalSize : 1,
60
+ id: opts.id,
61
+ originalArrayIndex: opts.originalArrayIndex ?? 0
62
+ };
63
+ }
64
+ async function compressImages(images, options = {}, statusCallback) {
65
+ const successful = [];
66
+ const failed = [];
67
+ const concurrency = Math.max(
68
+ 1,
69
+ options.concurrency ?? DEFAULT_NATIVE_CONCURRENCY
70
+ );
71
+ const { concurrency: _drop, ...passThrough } = options;
72
+ void _drop;
73
+ let cursor = 0;
74
+ const worker = async () => {
75
+ while (true) {
76
+ const index = cursor++;
77
+ if (index >= images.length) return;
78
+ const img = images[index];
79
+ if (!img) return;
80
+ try {
81
+ const r = await compressImage({
82
+ uri: img.uri,
83
+ filename: img.filename,
84
+ options: passThrough,
85
+ originalArrayIndex: index,
86
+ id: img.id,
87
+ statusCallback: (s) => statusCallback?.(img.id, s, index)
88
+ });
89
+ successful.push(r);
90
+ } catch (err) {
91
+ failed.push({
92
+ filename: img.filename,
93
+ originalUri: img.uri,
94
+ error: err instanceof Error ? err : new Error(String(err)),
95
+ id: img.id,
96
+ originalArrayIndex: index
97
+ });
98
+ }
99
+ }
100
+ };
101
+ await Promise.all(Array.from({ length: concurrency }, worker));
102
+ successful.sort((a, b) => a.originalArrayIndex - b.originalArrayIndex);
103
+ failed.sort((a, b) => a.originalArrayIndex - b.originalArrayIndex);
104
+ return { successful, failed };
105
+ }
106
+ export {
107
+ DEFAULT_COMPRESSION_OPTIONS,
108
+ DEFAULT_NATIVE_CONCURRENCY,
109
+ MAX_UPLOAD_DIMENSION,
110
+ compressImage,
111
+ compressImages
112
+ };
113
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/compression.ts","../src/constants.ts"],"sourcesContent":["/**\n * Native image compression via react-native-compressor.\n *\n * Why react-native-compressor instead of expo-image-manipulator:\n *\n * - iOS: uses CGImageDestination + libjpeg-turbo natively. No JS-bridge\n * round trip per pixel, no JSON serialization of pixel arrays.\n * - Android: uses BitmapFactory + Bitmap.compress() on a background\n * thread, with downsampling at the decode step (inSampleSize) so a\n * 12MP source never fully decodes if the target is 2880px.\n * - HEIC auto-decodes on both platforms (CoreImage / android-heif).\n *\n * In practice, on a 2019 iPhone XR, a 12MP HEIC (~4MB) → 2880px JPEG\n * q=0.85 takes ~250-350ms. The same operation via\n * expo-image-manipulator takes 2-4 seconds (JS bridge + intermediate\n * bitmap allocation in the JS heap). The 10× delta is what the user\n * was hitting in apps/asset-lab-mobile.\n */\n\nimport { getImageMetaData, Image as RNCImage } from \"react-native-compressor\";\nimport {\n DEFAULT_COMPRESSION_OPTIONS,\n DEFAULT_NATIVE_CONCURRENCY,\n} from \"./constants\";\nimport type {\n CompressedResult,\n CompressionError,\n CompressionOptions,\n CompressionStatusKey,\n} from \"./types\";\n\nexport type CompressInput = {\n /** `file://...`, `content://...`, `ph://...`, or remote URL. */\n uri: string;\n /** Display name. Used in result + error reports. */\n filename: string;\n /** Caller-supplied stable id; passed back unchanged. */\n id?: string;\n};\n\n/**\n * Read a file's byte size from its uri. react-native-compressor's\n * `getImageMetaData` returns size in bytes for local files (it stat()s\n * the path under the hood) — falls back to 0 for remote URLs (we don't\n * try to HEAD them; the caller can pass the size if it knows).\n */\nasync function getSizeBytes(uri: string): Promise<number> {\n try {\n const meta = await getImageMetaData(uri);\n return typeof meta.size === \"number\" ? meta.size : 0;\n } catch {\n return 0;\n }\n}\n\n/**\n * Compress a single image. Designed to be called directly when you only\n * have one file; otherwise use `compressImages` for batched throughput\n * control.\n */\nexport async function compressImage(opts: {\n uri: string;\n filename: string;\n options?: CompressionOptions;\n originalArrayIndex?: number;\n id?: string;\n statusCallback?: (status: CompressionStatusKey) => void;\n}): Promise<CompressedResult> {\n const opt = { ...DEFAULT_COMPRESSION_OPTIONS, ...opts.options };\n\n // skipCompression escape hatch: report original as-is without touching disk.\n if (opts.options?.skipCompression) {\n const size = await getSizeBytes(opts.uri);\n opts.statusCallback?.(\"skipped\");\n return {\n uri: opts.uri,\n filename: opts.filename,\n size,\n mimeType: \"image/jpeg\",\n originalSize: size,\n compressionRatio: 1,\n id: opts.id,\n originalArrayIndex: opts.originalArrayIndex ?? 0,\n };\n }\n\n const originalSize = await getSizeBytes(opts.uri);\n\n opts.statusCallback?.(\n opts.options?.keepOriginalDimensions\n ? \"compressingKeepingDimensions\"\n : \"compressing\",\n );\n\n // Manual mode pins quality + max dims (auto-mode adapts based on input\n // size — useful for chat apps where speed > predictability, but for our\n // upload pipeline we want deterministic output sizes).\n const compressedUri = await RNCImage.compress(opts.uri, {\n compressionMethod: \"manual\",\n quality: opt.quality,\n maxWidth: opts.options?.keepOriginalDimensions\n ? Number.POSITIVE_INFINITY\n : opt.maxWidth,\n maxHeight: opts.options?.keepOriginalDimensions\n ? Number.POSITIVE_INFINITY\n : opt.maxHeight,\n output: \"jpg\",\n returnableOutputType: \"uri\",\n });\n\n const compressedSize = await getSizeBytes(compressedUri);\n opts.statusCallback?.(\"done\");\n\n return {\n uri: compressedUri,\n filename: opts.filename.replace(/\\.(heic|heif|png|webp)$/i, \".jpg\"),\n size: compressedSize,\n mimeType: \"image/jpeg\",\n originalSize,\n compressionRatio: originalSize > 0 ? compressedSize / originalSize : 1,\n id: opts.id,\n originalArrayIndex: opts.originalArrayIndex ?? 0,\n };\n}\n\n/**\n * Compress many images with bounded concurrency. Failures are collected\n * — they don't reject the overall promise, so one bad file can't sink a\n * whole batch.\n *\n * Default concurrency = 1 (sequential). Bump to 2 on flagship devices if\n * profiling shows headroom; going higher tends to thermally throttle.\n */\nexport async function compressImages(\n images: CompressInput[],\n options: CompressionOptions & { concurrency?: number } = {},\n statusCallback?: (\n id: string | undefined,\n status: CompressionStatusKey,\n index: number,\n ) => void,\n): Promise<{ successful: CompressedResult[]; failed: CompressionError[] }> {\n const successful: CompressedResult[] = [];\n const failed: CompressionError[] = [];\n const concurrency = Math.max(\n 1,\n options.concurrency ?? DEFAULT_NATIVE_CONCURRENCY,\n );\n const { concurrency: _drop, ...passThrough } = options;\n void _drop;\n\n let cursor = 0;\n const worker = async (): Promise<void> => {\n while (true) {\n const index = cursor++;\n if (index >= images.length) return;\n const img = images[index];\n if (!img) return;\n try {\n const r = await compressImage({\n uri: img.uri,\n filename: img.filename,\n options: passThrough,\n originalArrayIndex: index,\n id: img.id,\n statusCallback: (s) => statusCallback?.(img.id, s, index),\n });\n successful.push(r);\n } catch (err) {\n failed.push({\n filename: img.filename,\n originalUri: img.uri,\n error: err instanceof Error ? err : new Error(String(err)),\n id: img.id,\n originalArrayIndex: index,\n });\n }\n }\n };\n\n await Promise.all(Array.from({ length: concurrency }, worker));\n\n // Restore caller order (workers complete out-of-order).\n successful.sort((a, b) => a.originalArrayIndex - b.originalArrayIndex);\n failed.sort((a, b) => a.originalArrayIndex - b.originalArrayIndex);\n\n return { successful, failed };\n}\n","/**\n * Defaults for the native compressor. Values intentionally match\n * @nitida/asset-compressor-web so a single SDK call produces\n * predictable upload sizes across platforms.\n *\n * Why JPEG (not WebP) on device:\n *\n * react-native-compressor's native modules only expose `output: 'jpg'\n * | 'png'`. Adding WebP would require shipping libwebp on iOS (Apple\n * pulled native WebP encoding from CoreImage) and binding it via a\n * custom Turbo Module. The marginal bandwidth win (~25% smaller than\n * JPEG q=0.85) is not worth the binary-size + maintenance cost\n * because the asset-manager re-encodes EVERY uploaded image to WebP\n * server-side anyway when generating size variants. So:\n *\n * - Device → JPEG q=0.85, 2880px max side (libjpeg-turbo native,\n * ~5–10× faster than expo-image-manipulator's WebP path)\n * - Server → WebP variants at `s|m|l|x` presets + on-the-fly\n * transforms\n *\n * Net result: the user uploads ~80% smaller bytes than a raw\n * 12MP iPhone HEIC/JPEG, the server re-encodes once, every CDN\n * variant is WebP. Win-win-win.\n */\n\nimport type { CompressionOptions } from \"./types\";\n\n/** 0.85 ≈ visually lossless for product photos (same as web compressor). */\nconst STANDARD_QUALITY = 0.85;\n\n/** Maximum dimension (longest side) of the compressed output. */\nexport const MAX_UPLOAD_DIMENSION = 2880;\n\nexport const DEFAULT_COMPRESSION_OPTIONS: Required<\n Omit<CompressionOptions, \"keepOriginalDimensions\" | \"skipCompression\">\n> = {\n quality: STANDARD_QUALITY,\n maxWidth: MAX_UPLOAD_DIMENSION,\n maxHeight: MAX_UPLOAD_DIMENSION,\n};\n\n/**\n * Sequential by default. Mobile devices have thermal + memory constraints\n * that make parallel compression a net loss above 2 concurrent encodes\n * (the GPU/CPU sharing model on phones doesn't scale like a laptop). The\n * caller can override via `compressImages(..., { concurrency: 2 })`.\n */\nexport const DEFAULT_NATIVE_CONCURRENCY = 1;\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@nitida/asset-compressor-native",
3
+ "version": "0.2.0",
4
+ "description": "Native (iOS/Android) image compression for the @aquienpz upload pipeline. JS API mirrors @nitida/asset-compressor-web so call sites are identical across web and React Native.",
5
+ "private": false,
6
+ "publishConfig": {
7
+ "access": "public",
8
+ "provenance": false
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/espaciofuturoio/aquienpz.git",
13
+ "directory": "packages/asset-compressor-native"
14
+ },
15
+ "homepage": "https://github.com/espaciofuturoio/aquienpz/tree/main/packages/asset-compressor-native",
16
+ "license": "UNLICENSED",
17
+ "type": "module",
18
+ "sideEffects": false,
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "import": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ }
28
+ },
29
+ "files": [
30
+ "dist/**",
31
+ "src/**"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "build:watch": "tsup --watch",
36
+ "prepublishOnly": "bun run build",
37
+ "type-check": "tsc --noEmit"
38
+ },
39
+ "peerDependencies": {
40
+ "react-native": "*",
41
+ "react-native-compressor": ">=1.10.0"
42
+ },
43
+ "devDependencies": {
44
+ "react-native": "0.85.3",
45
+ "react-native-compressor": "^1.18.2",
46
+ "tsup": "^8.5.1",
47
+ "typescript": "^6.0.3"
48
+ }
49
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Native image compression via react-native-compressor.
3
+ *
4
+ * Why react-native-compressor instead of expo-image-manipulator:
5
+ *
6
+ * - iOS: uses CGImageDestination + libjpeg-turbo natively. No JS-bridge
7
+ * round trip per pixel, no JSON serialization of pixel arrays.
8
+ * - Android: uses BitmapFactory + Bitmap.compress() on a background
9
+ * thread, with downsampling at the decode step (inSampleSize) so a
10
+ * 12MP source never fully decodes if the target is 2880px.
11
+ * - HEIC auto-decodes on both platforms (CoreImage / android-heif).
12
+ *
13
+ * In practice, on a 2019 iPhone XR, a 12MP HEIC (~4MB) → 2880px JPEG
14
+ * q=0.85 takes ~250-350ms. The same operation via
15
+ * expo-image-manipulator takes 2-4 seconds (JS bridge + intermediate
16
+ * bitmap allocation in the JS heap). The 10× delta is what the user
17
+ * was hitting in apps/asset-lab-mobile.
18
+ */
19
+
20
+ import { getImageMetaData, Image as RNCImage } from "react-native-compressor";
21
+ import {
22
+ DEFAULT_COMPRESSION_OPTIONS,
23
+ DEFAULT_NATIVE_CONCURRENCY,
24
+ } from "./constants";
25
+ import type {
26
+ CompressedResult,
27
+ CompressionError,
28
+ CompressionOptions,
29
+ CompressionStatusKey,
30
+ } from "./types";
31
+
32
+ export type CompressInput = {
33
+ /** `file://...`, `content://...`, `ph://...`, or remote URL. */
34
+ uri: string;
35
+ /** Display name. Used in result + error reports. */
36
+ filename: string;
37
+ /** Caller-supplied stable id; passed back unchanged. */
38
+ id?: string;
39
+ };
40
+
41
+ /**
42
+ * Read a file's byte size from its uri. react-native-compressor's
43
+ * `getImageMetaData` returns size in bytes for local files (it stat()s
44
+ * the path under the hood) — falls back to 0 for remote URLs (we don't
45
+ * try to HEAD them; the caller can pass the size if it knows).
46
+ */
47
+ async function getSizeBytes(uri: string): Promise<number> {
48
+ try {
49
+ const meta = await getImageMetaData(uri);
50
+ return typeof meta.size === "number" ? meta.size : 0;
51
+ } catch {
52
+ return 0;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Compress a single image. Designed to be called directly when you only
58
+ * have one file; otherwise use `compressImages` for batched throughput
59
+ * control.
60
+ */
61
+ export async function compressImage(opts: {
62
+ uri: string;
63
+ filename: string;
64
+ options?: CompressionOptions;
65
+ originalArrayIndex?: number;
66
+ id?: string;
67
+ statusCallback?: (status: CompressionStatusKey) => void;
68
+ }): Promise<CompressedResult> {
69
+ const opt = { ...DEFAULT_COMPRESSION_OPTIONS, ...opts.options };
70
+
71
+ // skipCompression escape hatch: report original as-is without touching disk.
72
+ if (opts.options?.skipCompression) {
73
+ const size = await getSizeBytes(opts.uri);
74
+ opts.statusCallback?.("skipped");
75
+ return {
76
+ uri: opts.uri,
77
+ filename: opts.filename,
78
+ size,
79
+ mimeType: "image/jpeg",
80
+ originalSize: size,
81
+ compressionRatio: 1,
82
+ id: opts.id,
83
+ originalArrayIndex: opts.originalArrayIndex ?? 0,
84
+ };
85
+ }
86
+
87
+ const originalSize = await getSizeBytes(opts.uri);
88
+
89
+ opts.statusCallback?.(
90
+ opts.options?.keepOriginalDimensions
91
+ ? "compressingKeepingDimensions"
92
+ : "compressing",
93
+ );
94
+
95
+ // Manual mode pins quality + max dims (auto-mode adapts based on input
96
+ // size — useful for chat apps where speed > predictability, but for our
97
+ // upload pipeline we want deterministic output sizes).
98
+ const compressedUri = await RNCImage.compress(opts.uri, {
99
+ compressionMethod: "manual",
100
+ quality: opt.quality,
101
+ maxWidth: opts.options?.keepOriginalDimensions
102
+ ? Number.POSITIVE_INFINITY
103
+ : opt.maxWidth,
104
+ maxHeight: opts.options?.keepOriginalDimensions
105
+ ? Number.POSITIVE_INFINITY
106
+ : opt.maxHeight,
107
+ output: "jpg",
108
+ returnableOutputType: "uri",
109
+ });
110
+
111
+ const compressedSize = await getSizeBytes(compressedUri);
112
+ opts.statusCallback?.("done");
113
+
114
+ return {
115
+ uri: compressedUri,
116
+ filename: opts.filename.replace(/\.(heic|heif|png|webp)$/i, ".jpg"),
117
+ size: compressedSize,
118
+ mimeType: "image/jpeg",
119
+ originalSize,
120
+ compressionRatio: originalSize > 0 ? compressedSize / originalSize : 1,
121
+ id: opts.id,
122
+ originalArrayIndex: opts.originalArrayIndex ?? 0,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Compress many images with bounded concurrency. Failures are collected
128
+ * — they don't reject the overall promise, so one bad file can't sink a
129
+ * whole batch.
130
+ *
131
+ * Default concurrency = 1 (sequential). Bump to 2 on flagship devices if
132
+ * profiling shows headroom; going higher tends to thermally throttle.
133
+ */
134
+ export async function compressImages(
135
+ images: CompressInput[],
136
+ options: CompressionOptions & { concurrency?: number } = {},
137
+ statusCallback?: (
138
+ id: string | undefined,
139
+ status: CompressionStatusKey,
140
+ index: number,
141
+ ) => void,
142
+ ): Promise<{ successful: CompressedResult[]; failed: CompressionError[] }> {
143
+ const successful: CompressedResult[] = [];
144
+ const failed: CompressionError[] = [];
145
+ const concurrency = Math.max(
146
+ 1,
147
+ options.concurrency ?? DEFAULT_NATIVE_CONCURRENCY,
148
+ );
149
+ const { concurrency: _drop, ...passThrough } = options;
150
+ void _drop;
151
+
152
+ let cursor = 0;
153
+ const worker = async (): Promise<void> => {
154
+ while (true) {
155
+ const index = cursor++;
156
+ if (index >= images.length) return;
157
+ const img = images[index];
158
+ if (!img) return;
159
+ try {
160
+ const r = await compressImage({
161
+ uri: img.uri,
162
+ filename: img.filename,
163
+ options: passThrough,
164
+ originalArrayIndex: index,
165
+ id: img.id,
166
+ statusCallback: (s) => statusCallback?.(img.id, s, index),
167
+ });
168
+ successful.push(r);
169
+ } catch (err) {
170
+ failed.push({
171
+ filename: img.filename,
172
+ originalUri: img.uri,
173
+ error: err instanceof Error ? err : new Error(String(err)),
174
+ id: img.id,
175
+ originalArrayIndex: index,
176
+ });
177
+ }
178
+ }
179
+ };
180
+
181
+ await Promise.all(Array.from({ length: concurrency }, worker));
182
+
183
+ // Restore caller order (workers complete out-of-order).
184
+ successful.sort((a, b) => a.originalArrayIndex - b.originalArrayIndex);
185
+ failed.sort((a, b) => a.originalArrayIndex - b.originalArrayIndex);
186
+
187
+ return { successful, failed };
188
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Defaults for the native compressor. Values intentionally match
3
+ * @nitida/asset-compressor-web so a single SDK call produces
4
+ * predictable upload sizes across platforms.
5
+ *
6
+ * Why JPEG (not WebP) on device:
7
+ *
8
+ * react-native-compressor's native modules only expose `output: 'jpg'
9
+ * | 'png'`. Adding WebP would require shipping libwebp on iOS (Apple
10
+ * pulled native WebP encoding from CoreImage) and binding it via a
11
+ * custom Turbo Module. The marginal bandwidth win (~25% smaller than
12
+ * JPEG q=0.85) is not worth the binary-size + maintenance cost
13
+ * because the asset-manager re-encodes EVERY uploaded image to WebP
14
+ * server-side anyway when generating size variants. So:
15
+ *
16
+ * - Device → JPEG q=0.85, 2880px max side (libjpeg-turbo native,
17
+ * ~5–10× faster than expo-image-manipulator's WebP path)
18
+ * - Server → WebP variants at `s|m|l|x` presets + on-the-fly
19
+ * transforms
20
+ *
21
+ * Net result: the user uploads ~80% smaller bytes than a raw
22
+ * 12MP iPhone HEIC/JPEG, the server re-encodes once, every CDN
23
+ * variant is WebP. Win-win-win.
24
+ */
25
+
26
+ import type { CompressionOptions } from "./types";
27
+
28
+ /** 0.85 ≈ visually lossless for product photos (same as web compressor). */
29
+ const STANDARD_QUALITY = 0.85;
30
+
31
+ /** Maximum dimension (longest side) of the compressed output. */
32
+ export const MAX_UPLOAD_DIMENSION = 2880;
33
+
34
+ export const DEFAULT_COMPRESSION_OPTIONS: Required<
35
+ Omit<CompressionOptions, "keepOriginalDimensions" | "skipCompression">
36
+ > = {
37
+ quality: STANDARD_QUALITY,
38
+ maxWidth: MAX_UPLOAD_DIMENSION,
39
+ maxHeight: MAX_UPLOAD_DIMENSION,
40
+ };
41
+
42
+ /**
43
+ * Sequential by default. Mobile devices have thermal + memory constraints
44
+ * that make parallel compression a net loss above 2 concurrent encodes
45
+ * (the GPU/CPU sharing model on phones doesn't scale like a laptop). The
46
+ * caller can override via `compressImages(..., { concurrency: 2 })`.
47
+ */
48
+ export const DEFAULT_NATIVE_CONCURRENCY = 1;
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @nitida/asset-compressor-native — React Native image compression for
3
+ * the @aquienpz upload pipeline. Pairs with @aquienpz/asset-uploader-expo.
4
+ *
5
+ * Public API mirrors @nitida/asset-compressor-web so consumers can
6
+ * write platform-agnostic code:
7
+ *
8
+ * const { compressImage } = Platform.OS === 'web'
9
+ * ? await import('@nitida/asset-compressor-web')
10
+ * : await import('@nitida/asset-compressor-native');
11
+ *
12
+ * Or (preferred): let the `@nitida/sdk` web/native sub-paths pick the
13
+ * right module automatically — see SDK README.
14
+ *
15
+ * Peer deps:
16
+ * - react-native (>=0.74)
17
+ * - react-native-compressor (>=1.10)
18
+ *
19
+ * Both must be installed in the consuming app. For Expo, run
20
+ * `npx expo install react-native-compressor` and add the config-plugin
21
+ * stanza to app.json (see react-native-compressor docs §Expo).
22
+ */
23
+
24
+ export {
25
+ type CompressInput,
26
+ compressImage,
27
+ compressImages,
28
+ } from "./compression";
29
+ export {
30
+ DEFAULT_COMPRESSION_OPTIONS,
31
+ DEFAULT_NATIVE_CONCURRENCY,
32
+ MAX_UPLOAD_DIMENSION,
33
+ } from "./constants";
34
+ export type {
35
+ CompressedResult,
36
+ CompressionError,
37
+ CompressionOptions,
38
+ CompressionStatusKey,
39
+ } from "./types";
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Public types for @nitida/asset-compressor-native.
3
+ *
4
+ * Mirrors @nitida/asset-compressor-web's surface so a single SDK call
5
+ * (`aq.upload(file, { compress: true })`) works identically across web
6
+ * and React Native. The only platform-specific divergence: RN inputs are
7
+ * file `uri` strings (e.g. `file:///...`, `content://...`, `ph://...`)
8
+ * rather than browser `Blob`s, since `Blob` on Hermes is a thin wrapper
9
+ * that can't be read efficiently and react-native-compressor operates on
10
+ * URIs natively.
11
+ */
12
+
13
+ export type CompressionStatusKey =
14
+ | "convertingHeic"
15
+ | "compressing"
16
+ | "compressingKeepingDimensions"
17
+ | "skipped"
18
+ | "done";
19
+
20
+ /**
21
+ * Subset of react-native-compressor's options we expose. Output format
22
+ * intentionally omitted from the public surface: we always emit JPEG on
23
+ * device (see `constants.ts` rationale). HEIC inputs are auto-decoded
24
+ * by iOS ImageIO and Android's BitmapFactory inside the native module,
25
+ * so no separate `convertingHeic` step is needed — react-native-compressor
26
+ * handles it transparently.
27
+ */
28
+ export type CompressionOptions = {
29
+ /** 0..1, defaults to 0.85 — matches web compressor */
30
+ quality?: number;
31
+ /** Pixels; defaults to 2880 (longest side cap) */
32
+ maxWidth?: number;
33
+ /** Pixels; defaults to 2880 (longest side cap) */
34
+ maxHeight?: number;
35
+ /** If true, ignore maxWidth/Height and keep source dimensions. */
36
+ keepOriginalDimensions?: boolean;
37
+ /** Caller escape hatch — when true, compression is bypassed entirely. */
38
+ skipCompression?: boolean;
39
+ };
40
+
41
+ /**
42
+ * Result of a single compression. `uri` points to the compressed file on
43
+ * the device filesystem; the caller is responsible for uploading it and
44
+ * cleaning up (react-native-compressor writes to the app cache dir,
45
+ * which iOS/Android will reclaim under memory pressure).
46
+ */
47
+ export type CompressedResult = {
48
+ /** `file://...` path to the compressed JPEG on disk. */
49
+ uri: string;
50
+ filename: string;
51
+ /** Bytes of the compressed output. */
52
+ size: number;
53
+ /** Mime type of the output — always `"image/jpeg"` for now. */
54
+ mimeType: "image/jpeg";
55
+ /** Original (pre-compression) size in bytes. */
56
+ originalSize: number;
57
+ /** size / originalSize. <1 = saved bytes. */
58
+ compressionRatio: number;
59
+ /** Caller-supplied stable id, echoed back. */
60
+ id?: string;
61
+ originalArrayIndex: number;
62
+ };
63
+
64
+ export type CompressionError = {
65
+ filename: string;
66
+ originalUri: string;
67
+ error: Error;
68
+ id?: string;
69
+ originalArrayIndex: number;
70
+ };