@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 +92 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.js +113 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/compression.ts +188 -0
- package/src/constants.ts +48 -0
- package/src/index.ts +39 -0
- package/src/types.ts +70 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
};
|