@aquienpz/asset-compressor-web 0.4.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/package.json +38 -0
- package/src/compression.ts +133 -0
- package/src/constants.ts +79 -0
- package/src/heic-converter.ts +29 -0
- package/src/index.ts +27 -0
- package/src/types.ts +53 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aquienpz/asset-compressor-web",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Browser-side image compression (compressorjs + heic2any) for the @aquienpz asset platform.",
|
|
5
|
+
"private": false,
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public",
|
|
8
|
+
"provenance": true
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/espaciofuturoio/aquienpz.git",
|
|
13
|
+
"directory": "packages/asset-compressor-web"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/espaciofuturoio/aquienpz/tree/main/packages/asset-compressor-web",
|
|
16
|
+
"license": "UNLICENSED",
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./src/index.ts",
|
|
21
|
+
"types": "./src/index.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src/**"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"type-check": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"compressorjs": "^1.2.1",
|
|
32
|
+
"heic2any": "^0.0.4",
|
|
33
|
+
"p-limit": "^7.1.1"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"typescript": "^6.0.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side image compression. Wraps `compressorjs` (Canvas-based) +
|
|
3
|
+
* lazy HEIC fallback. Suitable for product photos, listing photos,
|
|
4
|
+
* receipts — anything you want to shrink BEFORE upload to save bandwidth
|
|
5
|
+
* and let the server-side variant generator focus on resizing, not on
|
|
6
|
+
* decoding 50MB iPhone JPEGs.
|
|
7
|
+
*
|
|
8
|
+
* Status callback is intentionally per-file (not per-batch) so a UI can
|
|
9
|
+
* show "converting HEIC…" / "compressing…" per row.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import pLimit from "p-limit";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_COMPRESSION_OPTIONS,
|
|
15
|
+
DEFAULT_CPU_WORK_CONCURRENCY,
|
|
16
|
+
getOptimalCpuConcurrency,
|
|
17
|
+
isHeic,
|
|
18
|
+
} from "./constants";
|
|
19
|
+
import type {
|
|
20
|
+
CompressedResult,
|
|
21
|
+
CompressionError,
|
|
22
|
+
CompressionOptions,
|
|
23
|
+
CompressionStatusKey,
|
|
24
|
+
} from "./types";
|
|
25
|
+
|
|
26
|
+
export type CompressInput = {
|
|
27
|
+
blob: Blob;
|
|
28
|
+
filename: string;
|
|
29
|
+
/** Caller-supplied stable id; passed back unchanged in result/error */
|
|
30
|
+
id?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compress a single image. Designed to be called directly when you only
|
|
35
|
+
* have one file; otherwise use `compressImages` for batched concurrency.
|
|
36
|
+
*/
|
|
37
|
+
export async function compressImage(opts: {
|
|
38
|
+
blob: Blob;
|
|
39
|
+
filename: string;
|
|
40
|
+
options?: CompressionOptions;
|
|
41
|
+
originalArrayIndex?: number;
|
|
42
|
+
statusCallback?: (status: CompressionStatusKey) => void;
|
|
43
|
+
}): Promise<CompressedResult> {
|
|
44
|
+
const opt = { ...DEFAULT_COMPRESSION_OPTIONS, ...opts.options };
|
|
45
|
+
if (opts.options?.keepOriginalDimensions) {
|
|
46
|
+
opt.maxWidth = Number.POSITIVE_INFINITY;
|
|
47
|
+
opt.maxHeight = Number.POSITIVE_INFINITY;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let processedBlob = opts.blob;
|
|
51
|
+
let processedFilename = opts.filename;
|
|
52
|
+
|
|
53
|
+
// HEIC → JPEG first, then compressor handles the rest.
|
|
54
|
+
if (opts.blob instanceof File && isHeic(opts.blob)) {
|
|
55
|
+
opts.statusCallback?.("convertingHeic");
|
|
56
|
+
const { convertHeicFile } = await import("./heic-converter");
|
|
57
|
+
const converted = await convertHeicFile(opts.blob, opt.quality);
|
|
58
|
+
processedBlob = converted;
|
|
59
|
+
processedFilename = converted.name;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
opts.statusCallback?.(
|
|
63
|
+
opts.options?.keepOriginalDimensions ? "compressingKeepingDimensions" : "compressing",
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const mod = await import("compressorjs");
|
|
67
|
+
const Compressor = (mod as { default: typeof import("compressorjs").default }).default;
|
|
68
|
+
|
|
69
|
+
return new Promise<CompressedResult>((resolve, reject) => {
|
|
70
|
+
new Compressor(processedBlob, {
|
|
71
|
+
...opt,
|
|
72
|
+
success(out) {
|
|
73
|
+
const originalSize = opts.blob.size;
|
|
74
|
+
const compressedSize = out.size;
|
|
75
|
+
opts.statusCallback?.("done");
|
|
76
|
+
resolve({
|
|
77
|
+
blob: out,
|
|
78
|
+
filename: processedFilename,
|
|
79
|
+
originalSize,
|
|
80
|
+
compressedSize,
|
|
81
|
+
compressionRatio: originalSize > 0 ? compressedSize / originalSize : 1,
|
|
82
|
+
originalArrayIndex: opts.originalArrayIndex ?? 0,
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
error(err) {
|
|
86
|
+
reject(
|
|
87
|
+
new Error(`compress failed for ${processedFilename}: ${err.message}`),
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compress many images with concurrency control. Failures are returned
|
|
96
|
+
* — they don't reject the overall promise, so a single bad file can't
|
|
97
|
+
* sink a whole batch.
|
|
98
|
+
*/
|
|
99
|
+
export async function compressImages(
|
|
100
|
+
images: CompressInput[],
|
|
101
|
+
options: CompressionOptions = {},
|
|
102
|
+
statusCallback?: (id: string, status: CompressionStatusKey) => void,
|
|
103
|
+
): Promise<{ successful: CompressedResult[]; failed: CompressionError[] }> {
|
|
104
|
+
const successful: CompressedResult[] = [];
|
|
105
|
+
const failed: CompressionError[] = [];
|
|
106
|
+
const limit = pLimit(getOptimalCpuConcurrency(DEFAULT_CPU_WORK_CONCURRENCY));
|
|
107
|
+
|
|
108
|
+
await Promise.all(
|
|
109
|
+
images.map((img, index) =>
|
|
110
|
+
limit(async () => {
|
|
111
|
+
try {
|
|
112
|
+
const r = await compressImage({
|
|
113
|
+
blob: img.blob,
|
|
114
|
+
filename: img.filename,
|
|
115
|
+
options,
|
|
116
|
+
originalArrayIndex: index,
|
|
117
|
+
statusCallback: img.id ? (s) => statusCallback?.(img.id!, s) : undefined,
|
|
118
|
+
});
|
|
119
|
+
successful.push(r);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
failed.push({
|
|
122
|
+
filename: img.filename,
|
|
123
|
+
originalBlob: img.blob,
|
|
124
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
125
|
+
originalArrayIndex: index,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}),
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return { successful, failed };
|
|
133
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defaults for the web compressor. Values copied from
|
|
3
|
+
* neo-real-estate-v2 production tuning (LISTING_STANDARD_*) so first-time
|
|
4
|
+
* adopters get the same field-tested behavior without reading source.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CompressionOptions } from "./types";
|
|
8
|
+
|
|
9
|
+
/** Quality on a 0..1 scale. 0.85 ≈ visually lossless for product photos. */
|
|
10
|
+
const STANDARD_QUALITY = 0.85;
|
|
11
|
+
|
|
12
|
+
/** Maximum dimension (longest side) of the compressed output. */
|
|
13
|
+
export const MAX_UPLOAD_DIMENSION = 2880;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Defaults: compression ALWAYS runs and ALWAYS outputs WebP regardless of
|
|
17
|
+
* source format. Why:
|
|
18
|
+
*
|
|
19
|
+
* - WebP beats JPEG (~30% smaller at same quality) and crushes PNG
|
|
20
|
+
* (~70% smaller for photos), and is universally supported in browsers
|
|
21
|
+
* since 2020.
|
|
22
|
+
* - The asset-manager generates WebP variants server-side anyway, so
|
|
23
|
+
* uploading WebP costs nothing in fidelity but saves real bandwidth.
|
|
24
|
+
* - We DO NOT use compressorjs's `convertSize` gate (which would skip
|
|
25
|
+
* re-encoding for files under 5MB) because users expect the upload
|
|
26
|
+
* row to show a savings ratio for every image, not just the big ones.
|
|
27
|
+
*
|
|
28
|
+
* Override per call if a use case needs the original format preserved
|
|
29
|
+
* (e.g. uploading a PNG for icon-extraction where the alpha channel must
|
|
30
|
+
* be exact).
|
|
31
|
+
*/
|
|
32
|
+
export const DEFAULT_COMPRESSION_OPTIONS: Required<
|
|
33
|
+
Omit<CompressionOptions, "keepOriginalDimensions" | "skipCompression">
|
|
34
|
+
> = {
|
|
35
|
+
quality: STANDARD_QUALITY,
|
|
36
|
+
maxWidth: MAX_UPLOAD_DIMENSION,
|
|
37
|
+
maxHeight: MAX_UPLOAD_DIMENSION,
|
|
38
|
+
// Force WebP output for every image — compressorjs recompresses through
|
|
39
|
+
// canvas regardless of source size when mimeType is set.
|
|
40
|
+
mimeType: "image/webp",
|
|
41
|
+
// Setting this very low ensures the convertSize gate never blocks our
|
|
42
|
+
// re-encode (it would otherwise pass through PNGs < this threshold).
|
|
43
|
+
convertSize: 0,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Process N images concurrently. Empirically a sweet-spot on 8-core MBA. */
|
|
47
|
+
export const DEFAULT_CPU_WORK_CONCURRENCY = 2;
|
|
48
|
+
|
|
49
|
+
/** Mobile defaults to sequential to avoid OOM on low-end Android. */
|
|
50
|
+
export const BATCH_SIZE_MOBILE = 1;
|
|
51
|
+
export const BATCH_SIZE_DESKTOP = 4;
|
|
52
|
+
|
|
53
|
+
const DEFAULT_MAX_CPU_CORES = 8;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Adaptive concurrency: respect navigator.hardwareConcurrency but cap so we
|
|
57
|
+
* don't fan out to 32+ workers on big iron.
|
|
58
|
+
*/
|
|
59
|
+
export function getOptimalCpuConcurrency(
|
|
60
|
+
defaultConcurrency = 1,
|
|
61
|
+
maxCores = DEFAULT_MAX_CPU_CORES,
|
|
62
|
+
): number {
|
|
63
|
+
if (typeof navigator === "undefined") return defaultConcurrency;
|
|
64
|
+
const cores = navigator.hardwareConcurrency || defaultConcurrency;
|
|
65
|
+
if (defaultConcurrency === 1) return 1;
|
|
66
|
+
return Math.min(Math.max(cores, defaultConcurrency), maxCores);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const HEIC_MIME_TYPES = ["image/heic", "image/heif"];
|
|
70
|
+
export const HEIC_EXTENSIONS = [".heic", ".heif"];
|
|
71
|
+
|
|
72
|
+
export function isHeic(file: Blob | File): boolean {
|
|
73
|
+
if (file.type && HEIC_MIME_TYPES.includes(file.type.toLowerCase())) return true;
|
|
74
|
+
if (file instanceof File) {
|
|
75
|
+
const lower = file.name.toLowerCase();
|
|
76
|
+
return HEIC_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HEIC → JPEG conversion via lazy `heic2any`.
|
|
3
|
+
*
|
|
4
|
+
* Isolated in its own module so the heavy heic2any blob (~1MB) stays out
|
|
5
|
+
* of the initial bundle and only loads when a HEIC file actually appears.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
let heic2anyCache: unknown = null;
|
|
9
|
+
|
|
10
|
+
export async function convertHeicFile(file: File, quality = 0.85): Promise<File> {
|
|
11
|
+
if (!heic2anyCache) {
|
|
12
|
+
const mod = await import("heic2any");
|
|
13
|
+
heic2anyCache = (mod as { default?: unknown }).default ?? mod;
|
|
14
|
+
}
|
|
15
|
+
const heic2any = heic2anyCache as (opts: {
|
|
16
|
+
blob: Blob;
|
|
17
|
+
toType: string;
|
|
18
|
+
quality: number;
|
|
19
|
+
}) => Promise<Blob | Blob[]>;
|
|
20
|
+
|
|
21
|
+
const result = await heic2any({ blob: file, toType: "image/jpeg", quality });
|
|
22
|
+
const converted = Array.isArray(result) ? result[0] : result;
|
|
23
|
+
if (!converted || converted.size === 0) {
|
|
24
|
+
throw new Error("HEIC conversion produced an empty file");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const baseName = file.name.replace(/\.[^/.]+$/, "");
|
|
28
|
+
return new File([converted], `${baseName}.jpg`, { type: "image/jpeg" });
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aquienpz/asset-compressor-web — browser-side image compression for the
|
|
3
|
+
* upload pipeline. Pairs with @aquienpz/asset-uploader-web; designed to
|
|
4
|
+
* mirror the future @aquienpz/asset-compressor-expo native counterpart so
|
|
5
|
+
* call sites stay identical across web and mobile.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
type CompressInput,
|
|
10
|
+
compressImage,
|
|
11
|
+
compressImages,
|
|
12
|
+
} from "./compression";
|
|
13
|
+
export {
|
|
14
|
+
DEFAULT_COMPRESSION_OPTIONS,
|
|
15
|
+
getOptimalCpuConcurrency,
|
|
16
|
+
HEIC_EXTENSIONS,
|
|
17
|
+
HEIC_MIME_TYPES,
|
|
18
|
+
isHeic,
|
|
19
|
+
MAX_UPLOAD_DIMENSION,
|
|
20
|
+
} from "./constants";
|
|
21
|
+
export { convertHeicFile } from "./heic-converter";
|
|
22
|
+
export type {
|
|
23
|
+
CompressedResult,
|
|
24
|
+
CompressionError,
|
|
25
|
+
CompressionOptions,
|
|
26
|
+
CompressionStatusKey,
|
|
27
|
+
} from "./types";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for @aquienpz/asset-compressor-web.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the legacy neo-real-estate compressor types but trimmed to the
|
|
5
|
+
* surface actually consumed by the uploader. The mobile package (Expo
|
|
6
|
+
* native module) re-uses CompressionOptions/CompressionResult so the call
|
|
7
|
+
* site is identical regardless of platform.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type CompressionStatusKey =
|
|
11
|
+
| "convertingHeic"
|
|
12
|
+
| "compressing"
|
|
13
|
+
| "compressingKeepingDimensions"
|
|
14
|
+
| "skipped"
|
|
15
|
+
| "done";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Subset of `compressorjs` options we expose. Renderers should only need
|
|
19
|
+
* these — convertSize / strict / mimeType etc. stay defaulted.
|
|
20
|
+
*/
|
|
21
|
+
export type CompressionOptions = {
|
|
22
|
+
/** 0..1, defaults to 0.85 */
|
|
23
|
+
quality?: number;
|
|
24
|
+
/** Pixels; defaults to 2880 */
|
|
25
|
+
maxWidth?: number;
|
|
26
|
+
/** Pixels; defaults to 2880 */
|
|
27
|
+
maxHeight?: number;
|
|
28
|
+
/** Force a specific output mime ("image/jpeg" or "image/webp"). Default: keep original mime, fall back to JPEG. */
|
|
29
|
+
mimeType?: string;
|
|
30
|
+
/** Source files smaller than this are passed through. Default 5 MB. */
|
|
31
|
+
convertSize?: number;
|
|
32
|
+
/** If true, ignore maxWidth/Height and keep the source dimensions exactly. */
|
|
33
|
+
keepOriginalDimensions?: boolean;
|
|
34
|
+
/** Caller-controlled escape hatch — when true, compression is bypassed entirely. */
|
|
35
|
+
skipCompression?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CompressedResult = {
|
|
39
|
+
blob: Blob;
|
|
40
|
+
filename: string;
|
|
41
|
+
originalSize: number;
|
|
42
|
+
compressedSize: number;
|
|
43
|
+
/** compressedSize / originalSize. <1 = saved bytes. */
|
|
44
|
+
compressionRatio: number;
|
|
45
|
+
originalArrayIndex: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type CompressionError = {
|
|
49
|
+
filename: string;
|
|
50
|
+
originalBlob: Blob;
|
|
51
|
+
error: Error;
|
|
52
|
+
originalArrayIndex: number;
|
|
53
|
+
};
|